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
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "psych/pure"
|
|
5
|
+
|
|
6
|
+
module Yamlfmt
|
|
7
|
+
class Document
|
|
8
|
+
Line = Data.define(:text, :content, :ending, :number, :start_offset)
|
|
9
|
+
|
|
10
|
+
STANDARD_TAG_PREFIX = "tag:yaml.org,2002:"
|
|
11
|
+
BLOCK_SCALAR_STYLES = [Psych::Nodes::Scalar::LITERAL, Psych::Nodes::Scalar::FOLDED].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :source, :path, :ast, :lines
|
|
14
|
+
|
|
15
|
+
def initialize(source, path: "<unknown>")
|
|
16
|
+
@source = source.dup.freeze
|
|
17
|
+
@path = path.to_s
|
|
18
|
+
validate_encoding!
|
|
19
|
+
@lines = build_lines.freeze
|
|
20
|
+
@standard_stream = parse_standard
|
|
21
|
+
validate_supported!
|
|
22
|
+
@standard_document = @standard_stream.children.first
|
|
23
|
+
@ast = parse_pure
|
|
24
|
+
@node_ranges = build_node_ranges.freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def each_line(&block)
|
|
28
|
+
return enum_for(__method__) unless block
|
|
29
|
+
|
|
30
|
+
lines.each(&block)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def each_node(root = ast, &block)
|
|
34
|
+
return enum_for(__method__, root) unless block
|
|
35
|
+
return if root.nil? || root == false
|
|
36
|
+
|
|
37
|
+
yield root
|
|
38
|
+
root.children&.each { |child| each_node(child, &block) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def range_for(node)
|
|
42
|
+
@node_ranges[node.object_id]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def scalar_ranges(style: nil)
|
|
46
|
+
@scalar_ranges ||= {}
|
|
47
|
+
@scalar_ranges[style] ||= each_node.filter_map do |node|
|
|
48
|
+
next unless node.is_a?(Psych::Nodes::Scalar)
|
|
49
|
+
next unless style.nil? || node.style == style
|
|
50
|
+
|
|
51
|
+
range_for(node)
|
|
52
|
+
end.freeze
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def block_scalar?
|
|
56
|
+
standard_nodes.any? do |node|
|
|
57
|
+
node.is_a?(Psych::Nodes::Scalar) && BLOCK_SCALAR_STYLES.include?(node.style)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def empty_yaml?
|
|
62
|
+
@standard_document.nil?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def comment_values
|
|
66
|
+
return comment_only_values if empty_yaml?
|
|
67
|
+
|
|
68
|
+
seen = {}
|
|
69
|
+
values = each_node.flat_map do |node|
|
|
70
|
+
next [] unless node.respond_to?(:comments?) && node.comments?
|
|
71
|
+
|
|
72
|
+
(node.comments.leading + node.comments.trailing).filter_map do |comment|
|
|
73
|
+
next if seen[comment.object_id]
|
|
74
|
+
|
|
75
|
+
seen[comment.object_id] = true
|
|
76
|
+
normalize_comment(comment.value)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
values.sort
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def preferred_line_ending
|
|
84
|
+
endings = lines.filter_map { |line| line.ending unless line.ending.empty? }
|
|
85
|
+
return "\n" if endings.empty?
|
|
86
|
+
|
|
87
|
+
endings.tally.max_by { |ending, count| [count, -endings.index(ending)] }.first
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def line_and_column(byte_offset)
|
|
91
|
+
raise ArgumentError, "offset is outside the source" unless byte_offset.between?(0, source.bytesize)
|
|
92
|
+
|
|
93
|
+
line = lines.reverse_each.find { |candidate| candidate.start_offset <= byte_offset }
|
|
94
|
+
return [1, 1] unless line
|
|
95
|
+
|
|
96
|
+
prefix = source.byteslice(line.start_offset, byte_offset - line.start_offset)
|
|
97
|
+
[line.number, prefix.length + 1]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def validate_encoding!
|
|
103
|
+
if source.start_with?("\uFEFF")
|
|
104
|
+
raise UnsupportedFileError, "byte order marks are not supported"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
return if source.encoding == Encoding::UTF_8 && source.valid_encoding?
|
|
108
|
+
|
|
109
|
+
raise UnsupportedFileError, "only valid UTF-8 input is supported"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def parse_standard
|
|
113
|
+
Psych.parse_stream(source, filename: path)
|
|
114
|
+
rescue Psych::SyntaxError => error
|
|
115
|
+
raise ParseError, error.message
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def parse_pure
|
|
119
|
+
return nil if empty_yaml?
|
|
120
|
+
|
|
121
|
+
Psych::Pure.parse(source, filename: path, comments: true)
|
|
122
|
+
rescue Psych::SyntaxError => error
|
|
123
|
+
raise ParseError, error.message
|
|
124
|
+
rescue Psych::Pure::InternalException, NoMethodError => error
|
|
125
|
+
raise UnsupportedFileError, "psych-pure could not parse this document: #{error.message}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def validate_supported!
|
|
129
|
+
if @standard_stream.children.length > 1
|
|
130
|
+
raise UnsupportedFileError, "multiple YAML documents are not supported"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
standard_nodes.each do |node|
|
|
134
|
+
if custom_tag?(node)
|
|
135
|
+
raise UnsupportedFileError, "custom YAML tags are not supported"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
if node.is_a?(Psych::Nodes::Scalar) && node.anchor
|
|
139
|
+
raise UnsupportedFileError, "anchors on scalar values are not supported"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def custom_tag?(node)
|
|
145
|
+
return false unless node.respond_to?(:tag)
|
|
146
|
+
|
|
147
|
+
tag = node.tag
|
|
148
|
+
tag && tag != "!" && !tag.start_with?(STANDARD_TAG_PREFIX)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def standard_nodes
|
|
152
|
+
return [] unless @standard_stream
|
|
153
|
+
|
|
154
|
+
@standard_nodes ||= walk(@standard_stream).freeze
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def walk(node, result = [])
|
|
158
|
+
result << node
|
|
159
|
+
node.children&.each { |child| walk(child, result) }
|
|
160
|
+
result
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def build_node_ranges
|
|
164
|
+
return {} unless ast
|
|
165
|
+
|
|
166
|
+
pure_scalars = each_node.select { |node| node.is_a?(Psych::Nodes::Scalar) }
|
|
167
|
+
psych_scalars = standard_nodes.select { |node| node.is_a?(Psych::Nodes::Scalar) }
|
|
168
|
+
return {} unless pure_scalars.length == psych_scalars.length
|
|
169
|
+
|
|
170
|
+
pure_scalars.zip(psych_scalars).each_with_object({}) do |(pure_node, psych_node), ranges|
|
|
171
|
+
next unless pure_node.value == psych_node.value && pure_node.style == psych_node.style
|
|
172
|
+
|
|
173
|
+
range = range_from_location(psych_node)
|
|
174
|
+
next unless range
|
|
175
|
+
next unless source.byteslice(range.start_offset, range.length) == pure_node.source
|
|
176
|
+
|
|
177
|
+
ranges[pure_node.object_id] = range
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def range_from_location(node)
|
|
182
|
+
start_offset = byte_offset(node.start_line, node.start_column)
|
|
183
|
+
end_offset = byte_offset(node.end_line, node.end_column)
|
|
184
|
+
SourceRange.new(start_offset:, end_offset:)
|
|
185
|
+
rescue ArgumentError, IndexError
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def byte_offset(line_index, character_column)
|
|
190
|
+
line = lines.fetch(line_index)
|
|
191
|
+
prefix = line.text[0, character_column]
|
|
192
|
+
raise ArgumentError, "column is outside the line" unless prefix
|
|
193
|
+
|
|
194
|
+
line.start_offset + prefix.bytesize
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def build_lines
|
|
198
|
+
result = []
|
|
199
|
+
start_offset = 0
|
|
200
|
+
index = 0
|
|
201
|
+
|
|
202
|
+
while index < source.bytesize
|
|
203
|
+
byte = source.getbyte(index)
|
|
204
|
+
if byte == 13 || byte == 10
|
|
205
|
+
ending_length = (byte == 13 && source.getbyte(index + 1) == 10) ? 2 : 1
|
|
206
|
+
result << build_line(start_offset, index, ending_length, result.length + 1)
|
|
207
|
+
index += ending_length
|
|
208
|
+
start_offset = index
|
|
209
|
+
else
|
|
210
|
+
index += 1
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
result << build_line(start_offset, source.bytesize, 0, result.length + 1) if start_offset < source.bytesize
|
|
215
|
+
result
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def build_line(start_offset, content_end, ending_length, number)
|
|
219
|
+
content = source.byteslice(start_offset, content_end - start_offset)
|
|
220
|
+
ending = source.byteslice(content_end, ending_length)
|
|
221
|
+
Line.new(
|
|
222
|
+
text: "#{content}#{ending}",
|
|
223
|
+
content:,
|
|
224
|
+
ending:,
|
|
225
|
+
number:,
|
|
226
|
+
start_offset:
|
|
227
|
+
)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def comment_only_values
|
|
231
|
+
lines.filter_map do |line|
|
|
232
|
+
content = line.content.lstrip
|
|
233
|
+
normalize_comment(content) if content.start_with?("#")
|
|
234
|
+
end.sort
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def normalize_comment(value)
|
|
238
|
+
value.sub(/[ \t]+\z/, "")
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
data/lib/yamlfmt/edit.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yamlfmt
|
|
4
|
+
Edit = Data.define(:range, :replacement) do
|
|
5
|
+
def initialize(range:, replacement:)
|
|
6
|
+
raise ArgumentError, "range must be a SourceRange" unless range.is_a?(SourceRange)
|
|
7
|
+
raise ArgumentError, "replacement must be a String" unless replacement.is_a?(String)
|
|
8
|
+
|
|
9
|
+
super
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yamlfmt
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class ParseError < Error; end
|
|
7
|
+
class UnsupportedFileError < Error; end
|
|
8
|
+
class ValidationError < Error; end
|
|
9
|
+
class ConfigError < Error; end
|
|
10
|
+
class PathError < Error; end
|
|
11
|
+
|
|
12
|
+
class ConflictError < Error
|
|
13
|
+
attr_reader :findings
|
|
14
|
+
|
|
15
|
+
def initialize(findings)
|
|
16
|
+
@findings = findings.freeze
|
|
17
|
+
rule_ids = findings.map(&:rule_id).uniq.join(", ")
|
|
18
|
+
super("overlapping edits from: #{rule_ids}")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Yamlfmt
|
|
6
|
+
class ExcludeMatcher
|
|
7
|
+
FLAGS = File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB
|
|
8
|
+
|
|
9
|
+
def initialize(root:, patterns:)
|
|
10
|
+
@root = Pathname(File.expand_path(root))
|
|
11
|
+
@patterns = patterns
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def excluded?(path)
|
|
15
|
+
relative = relative_path(path)
|
|
16
|
+
return false unless relative
|
|
17
|
+
|
|
18
|
+
components = relative.split("/")
|
|
19
|
+
@patterns.any? { |pattern| match?(pattern, relative, components) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def relative_path(path)
|
|
25
|
+
relative = Pathname(File.expand_path(path)).relative_path_from(@root).to_s
|
|
26
|
+
return if relative == ".." || relative.start_with?("../")
|
|
27
|
+
|
|
28
|
+
relative
|
|
29
|
+
rescue ArgumentError
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def match?(pattern, relative, components)
|
|
34
|
+
if pattern.include?("/")
|
|
35
|
+
path_pattern_match?(pattern, relative)
|
|
36
|
+
elsif glob?(pattern)
|
|
37
|
+
components.any? { |component| File.fnmatch?(pattern, component, FLAGS) }
|
|
38
|
+
else
|
|
39
|
+
components.include?(pattern)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def path_pattern_match?(pattern, relative)
|
|
44
|
+
if pattern.end_with?("/**")
|
|
45
|
+
path_or_ancestor_match?(pattern.delete_suffix("/**"), relative)
|
|
46
|
+
elsif glob?(pattern)
|
|
47
|
+
path_or_ancestor_match?(pattern, relative)
|
|
48
|
+
else
|
|
49
|
+
relative == pattern || relative.start_with?("#{pattern}/")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def path_or_ancestor_match?(pattern, relative)
|
|
54
|
+
components = relative.split("/")
|
|
55
|
+
components.each_index.any? do |index|
|
|
56
|
+
ancestor = components.first(index + 1).join("/")
|
|
57
|
+
File.fnmatch?(pattern, ancestor, FLAGS)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def glob?(pattern)
|
|
62
|
+
pattern.match?(/[*?]/)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "find"
|
|
4
|
+
|
|
5
|
+
module Yamlfmt
|
|
6
|
+
class FileFinder
|
|
7
|
+
YAML_EXTENSIONS = %w[.yml .yaml].freeze
|
|
8
|
+
|
|
9
|
+
def initialize(cwd: Dir.pwd, exclude: [])
|
|
10
|
+
@cwd = File.expand_path(cwd)
|
|
11
|
+
@exclude_matcher = ExcludeMatcher.new(root: @cwd, patterns: exclude)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(paths = [])
|
|
15
|
+
roots = paths.empty? ? [@cwd] : paths.map { |path| File.expand_path(path, @cwd) }
|
|
16
|
+
files = {}
|
|
17
|
+
|
|
18
|
+
roots.each do |root|
|
|
19
|
+
validate_root!(root)
|
|
20
|
+
next if symlink_path?(root) || always_excluded?(root) || @exclude_matcher.excluded?(root)
|
|
21
|
+
|
|
22
|
+
if File.file?(root)
|
|
23
|
+
files[root] = true
|
|
24
|
+
elsif File.directory?(root)
|
|
25
|
+
find_yaml_files(root, files)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
files.keys.sort
|
|
30
|
+
rescue SystemCallError => error
|
|
31
|
+
raise PathError, error.message
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def validate_root!(root)
|
|
37
|
+
return if File.exist?(root)
|
|
38
|
+
|
|
39
|
+
raise PathError, "path does not exist: #{root}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def find_yaml_files(root, files)
|
|
43
|
+
Find.find(root) do |path|
|
|
44
|
+
if path != root && (symlink?(path) || always_excluded?(path) || @exclude_matcher.excluded?(path))
|
|
45
|
+
Find.prune if File.directory?(path)
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
files[path] = true if File.file?(path) && YAML_EXTENSIONS.include?(File.extname(path))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def symlink?(path)
|
|
54
|
+
File.symlink?(path)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def symlink_path?(path)
|
|
58
|
+
relative = Pathname(path).relative_path_from(Pathname(@cwd))
|
|
59
|
+
current = @cwd
|
|
60
|
+
|
|
61
|
+
relative.each_filename do |component|
|
|
62
|
+
current = File.expand_path(component, current)
|
|
63
|
+
return true if symlink?(current)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
false
|
|
67
|
+
rescue ArgumentError
|
|
68
|
+
symlink?(path)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def always_excluded?(path)
|
|
72
|
+
Pathname(path).each_filename.include?(".git")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yamlfmt
|
|
4
|
+
Finding = Data.define(:rule_id, :range, :message, :edit) do
|
|
5
|
+
def initialize(rule_id:, range:, message:, edit: nil)
|
|
6
|
+
raise ArgumentError, "range must be a SourceRange" unless range.is_a?(SourceRange)
|
|
7
|
+
raise ArgumentError, "edit must be an Edit or nil" unless edit.nil? || edit.is_a?(Edit)
|
|
8
|
+
|
|
9
|
+
super
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def autocorrectable?
|
|
13
|
+
!edit.nil?
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yamlfmt
|
|
4
|
+
class Processor
|
|
5
|
+
Result = Data.define(:document, :source, :formatted_source, :findings, :warnings) do
|
|
6
|
+
def changed?
|
|
7
|
+
source != formatted_source
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(corrector: Corrector.new, validator: SafetyValidator.new)
|
|
12
|
+
@corrector = corrector
|
|
13
|
+
@validator = validator
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(source, path: "<unknown>", rules: default_rules)
|
|
17
|
+
document = Document.new(source, path:)
|
|
18
|
+
findings = rules.flat_map { |rule| rule.call(document) }.freeze
|
|
19
|
+
formatted_source = @corrector.call(source, findings)
|
|
20
|
+
@validator.call(document, formatted_source) if formatted_source != source
|
|
21
|
+
warnings = block_scalar_warnings(document, rules).freeze
|
|
22
|
+
|
|
23
|
+
Result.new(document:, source:, formatted_source:, findings:, warnings:)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def default_rules
|
|
29
|
+
Rule::Registry.rules.map(&:new)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def block_scalar_warnings(document, rules)
|
|
33
|
+
return [] unless document.block_scalar?
|
|
34
|
+
return [] unless rules.any? { |rule| rule.is_a?(Rule::LineBased) }
|
|
35
|
+
|
|
36
|
+
["line-based rules were skipped because the file contains a block scalar"]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yamlfmt
|
|
4
|
+
module Rule
|
|
5
|
+
class AstBased < Base
|
|
6
|
+
Context = Data.define(:node, :document, :parent, :in_flow, :flow_mapping_key) do
|
|
7
|
+
def flow?
|
|
8
|
+
in_flow
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def flow_mapping_key?
|
|
12
|
+
flow_mapping_key
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(document)
|
|
17
|
+
each_context(document).flat_map do |context|
|
|
18
|
+
Array(check_node(context))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def check_node(_context)
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def each_context(document, node = document.ast, parent: nil, in_flow: false, flow_mapping_key: false, &block)
|
|
29
|
+
return enum_for(__method__, document, node, parent:, in_flow:, flow_mapping_key:) unless block
|
|
30
|
+
return if node.nil? || node == false
|
|
31
|
+
|
|
32
|
+
yield Context.new(node:, document:, parent:, in_flow:, flow_mapping_key:)
|
|
33
|
+
|
|
34
|
+
child_in_flow = in_flow || flow_collection?(node)
|
|
35
|
+
Array(node.children).each_with_index do |child, index|
|
|
36
|
+
each_context(
|
|
37
|
+
document,
|
|
38
|
+
child,
|
|
39
|
+
parent: node,
|
|
40
|
+
in_flow: child_in_flow,
|
|
41
|
+
flow_mapping_key: flow_mapping?(node) && index.even?,
|
|
42
|
+
&block
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def flow_collection?(node)
|
|
48
|
+
(node.is_a?(Psych::Nodes::Sequence) && node.style == Psych::Nodes::Sequence::FLOW) ||
|
|
49
|
+
flow_mapping?(node)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def flow_mapping?(node)
|
|
53
|
+
node.is_a?(Psych::Nodes::Mapping) && node.style == Psych::Nodes::Mapping::FLOW
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yamlfmt
|
|
4
|
+
module Rule
|
|
5
|
+
class Base
|
|
6
|
+
UNSET = Object.new.freeze
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def rule_id(value = UNSET)
|
|
10
|
+
return @rule_id if value.equal?(UNSET)
|
|
11
|
+
|
|
12
|
+
@rule_id = value.to_s.freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def default_config(value = UNSET)
|
|
16
|
+
return @default_config || {} if value.equal?(UNSET)
|
|
17
|
+
|
|
18
|
+
@default_config = value.freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def priority(value = UNSET)
|
|
22
|
+
return @priority || 100 if value.equal?(UNSET)
|
|
23
|
+
|
|
24
|
+
@priority = Integer(value)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def autocorrectable?
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate_config(_config)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
attr_reader :config
|
|
36
|
+
|
|
37
|
+
def initialize(config = {})
|
|
38
|
+
@config = self.class.default_config.merge(config).freeze
|
|
39
|
+
self.class.validate_config(@config)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def call(_document)
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def correction(range, message, replacement)
|
|
49
|
+
edit = Edit.new(range:, replacement:)
|
|
50
|
+
Finding.new(rule_id: self.class.rule_id, range:, message:, edit:)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yamlfmt
|
|
4
|
+
module Rule
|
|
5
|
+
class BlankLines < LineBased
|
|
6
|
+
rule_id "blank-lines"
|
|
7
|
+
priority 200
|
|
8
|
+
default_config max: 1
|
|
9
|
+
|
|
10
|
+
def self.validate_config(config)
|
|
11
|
+
max = config.fetch(:max)
|
|
12
|
+
return if max.is_a?(Integer) && max >= 0
|
|
13
|
+
|
|
14
|
+
raise ConfigError, "blank-lines.max must be a non-negative integer"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def check_line(context)
|
|
18
|
+
ranges = context.document.scalar_ranges
|
|
19
|
+
line = context.line
|
|
20
|
+
return unless blank?(line) && !inside_scalar?(line, ranges)
|
|
21
|
+
|
|
22
|
+
following = context.next_line
|
|
23
|
+
return if following.nil?
|
|
24
|
+
return if blank?(following) && !inside_scalar?(following, ranges)
|
|
25
|
+
|
|
26
|
+
finding_for_run(context.lines, run_start(context, ranges), context.index + 1)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def blank?(line)
|
|
32
|
+
line.content.match?(/\A[ \t]*\z/)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def inside_scalar?(line, ranges)
|
|
36
|
+
ranges.any? do |range|
|
|
37
|
+
range.start_offset < line.start_offset && line.start_offset < range.end_offset
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def run_start(context, ranges)
|
|
42
|
+
start = context.index
|
|
43
|
+
while start.positive?
|
|
44
|
+
previous = context.lines[start - 1]
|
|
45
|
+
break unless blank?(previous) && !inside_scalar?(previous, ranges)
|
|
46
|
+
|
|
47
|
+
start -= 1
|
|
48
|
+
end
|
|
49
|
+
start
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def finding_for_run(lines, run_start, run_end)
|
|
53
|
+
delete_start = run_start + config.fetch(:max)
|
|
54
|
+
return if delete_start >= run_end
|
|
55
|
+
|
|
56
|
+
range = SourceRange.new(
|
|
57
|
+
start_offset: lines.fetch(delete_start).start_offset,
|
|
58
|
+
end_offset: lines.fetch(run_end).start_offset
|
|
59
|
+
)
|
|
60
|
+
correction(range, "too many consecutive blank lines", "")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
Registry.register(BlankLines)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yamlfmt
|
|
4
|
+
module Rule
|
|
5
|
+
class FinalNewline < LineBased
|
|
6
|
+
rule_id "final-newline"
|
|
7
|
+
priority 300
|
|
8
|
+
|
|
9
|
+
def check_line(context)
|
|
10
|
+
return unless context.last?
|
|
11
|
+
|
|
12
|
+
last_content_line = context.lines.reverse_each.find { |line| line.content.match?(/[^ \t]/) }
|
|
13
|
+
return remove_whitespace_only_source(context.document) unless last_content_line
|
|
14
|
+
|
|
15
|
+
if last_content_line.ending.empty?
|
|
16
|
+
add_final_newline(context.document)
|
|
17
|
+
else
|
|
18
|
+
remove_extra_lines(context.document, last_content_line)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def remove_whitespace_only_source(document)
|
|
25
|
+
range = SourceRange.new(start_offset: 0, end_offset: document.source.bytesize)
|
|
26
|
+
correction(range, "whitespace-only file detected", "")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def add_final_newline(document)
|
|
30
|
+
offset = document.source.bytesize
|
|
31
|
+
range = SourceRange.new(start_offset: offset, end_offset: offset)
|
|
32
|
+
correction(range, "final newline missing", document.preferred_line_ending)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def remove_extra_lines(document, last_content_line)
|
|
36
|
+
start_offset = last_content_line.start_offset + last_content_line.text.bytesize
|
|
37
|
+
return if start_offset == document.source.bytesize
|
|
38
|
+
|
|
39
|
+
range = SourceRange.new(start_offset:, end_offset: document.source.bytesize)
|
|
40
|
+
correction(range, "extra final newlines detected", "")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
Registry.register(FinalNewline)
|
|
45
|
+
end
|
|
46
|
+
end
|