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.
@@ -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
@@ -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