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,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yamlfmt
4
+ module Rule
5
+ class LineBased < Base
6
+ Context = Data.define(:line, :index, :lines, :document) do
7
+ def previous_line
8
+ index.positive? ? lines[index - 1] : nil
9
+ end
10
+
11
+ def next_line
12
+ lines[index + 1]
13
+ end
14
+
15
+ def first?
16
+ index.zero?
17
+ end
18
+
19
+ def last?
20
+ index == lines.length - 1
21
+ end
22
+ end
23
+
24
+ def call(document)
25
+ return [] if document.block_scalar?
26
+
27
+ each_context(document).flat_map do |context|
28
+ Array(check_line(context))
29
+ end
30
+ end
31
+
32
+ def check_line(_context)
33
+ raise NotImplementedError
34
+ end
35
+
36
+ private
37
+
38
+ def each_context(document, &block)
39
+ return enum_for(__method__, document) unless block
40
+
41
+ lines = document.lines
42
+ lines.each_with_index do |line, index|
43
+ yield Context.new(line:, index:, lines:, document:)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yamlfmt
4
+ module Rule
5
+ module Registry
6
+ class << self
7
+ def register(rule_class)
8
+ id = rule_class.rule_id
9
+ raise ArgumentError, "rule must define an id" if id.nil? || id.empty?
10
+ raise ArgumentError, "rule #{id.inspect} is already registered" if rules_by_id.key?(id)
11
+
12
+ rules_by_id[id] = rule_class
13
+ end
14
+
15
+ def fetch(id)
16
+ rules_by_id.fetch(id)
17
+ end
18
+
19
+ def rules
20
+ rules_by_id.values.sort_by { |rule| [rule.priority, rule.rule_id] }
21
+ end
22
+
23
+ def ids
24
+ rules_by_id.keys.freeze
25
+ end
26
+
27
+ def clear
28
+ @rules_by_id = {}
29
+ end
30
+
31
+ private
32
+
33
+ def rules_by_id
34
+ @rules_by_id ||= {}
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yamlfmt
4
+ module Rule
5
+ class TrailingWhitespace < LineBased
6
+ rule_id "trailing-whitespace"
7
+ priority 100
8
+
9
+ def check_line(context)
10
+ line = context.line
11
+ whitespace = line.content[/[ \t]+\z/]
12
+ return unless whitespace
13
+
14
+ ws_start = line.content.bytesize - whitespace.bytesize
15
+ removable_start = removable_trailing_start(line, ws_start, double_quoted_scalar_ranges(context.document))
16
+ return if removable_start >= line.content.bytesize
17
+
18
+ range = SourceRange.new(
19
+ start_offset: line.start_offset + removable_start,
20
+ end_offset: line.start_offset + line.content.bytesize
21
+ )
22
+ correction(range, "trailing whitespace detected", "")
23
+ end
24
+
25
+ private
26
+
27
+ def double_quoted_scalar_ranges(document)
28
+ document.scalar_ranges(style: Psych::Nodes::Scalar::DOUBLE_QUOTED)
29
+ end
30
+
31
+ def removable_trailing_start(line, ws_start, scalar_ranges)
32
+ removable_start = ws_start
33
+ while removable_start < line.content.bytesize
34
+ byte = line.content.getbyte(removable_start)
35
+ break unless byte == 32 || byte == 9
36
+
37
+ offset = line.start_offset + removable_start
38
+ inside_scalar = scalar_ranges.any? { |range| range.start_offset <= offset && offset < range.end_offset }
39
+ break unless inside_scalar && escaped_whitespace?(line.content, removable_start)
40
+
41
+ removable_start += 1
42
+ end
43
+ removable_start
44
+ end
45
+
46
+ def escaped_whitespace?(content, byte_index)
47
+ backslashes = 0
48
+ position = byte_index - 1
49
+ while position >= 0 && content.getbyte(position) == 92
50
+ backslashes += 1
51
+ position -= 1
52
+ end
53
+ backslashes.odd?
54
+ end
55
+ end
56
+
57
+ Registry.register(TrailingWhitespace)
58
+ end
59
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yamlfmt
4
+ module Rule
5
+ class UnnecessaryQuotes < AstBased
6
+ QUOTED_STYLES = [Psych::Nodes::Scalar::SINGLE_QUOTED, Psych::Nodes::Scalar::DOUBLE_QUOTED].freeze
7
+ FLOW_INDICATORS = /[\[\]{},]/
8
+
9
+ rule_id "unnecessary-quotes"
10
+ priority 400
11
+
12
+ def check_node(context)
13
+ node = context.node
14
+ return unless node.is_a?(Psych::Nodes::Scalar)
15
+ return unless QUOTED_STYLES.include?(node.style)
16
+ return unless can_unquote?(node.value, in_flow: context.flow?)
17
+
18
+ range = context.document.range_for(node)
19
+ return unless range
20
+ return if multiline?(context.document.source, range)
21
+ return if context.flow_mapping_key? && missing_flow_key_separation?(context.document.source, range)
22
+
23
+ correction(range, "unnecessary quotes detected", node.value)
24
+ end
25
+
26
+ private
27
+
28
+ def can_unquote?(value, in_flow:)
29
+ return false unless value.is_a?(String) && Psych.dump(value) == "--- #{value}\n"
30
+
31
+ !in_flow || !value.match?(FLOW_INDICATORS)
32
+ end
33
+
34
+ def multiline?(source, range)
35
+ source.byteslice(range.start_offset, range.length).match?(/[\r\n]/)
36
+ end
37
+
38
+ def missing_flow_key_separation?(source, range)
39
+ offset = range.end_offset
40
+ offset += 1 while [9, 32].include?(source.getbyte(offset))
41
+ return false unless source.getbyte(offset) == 58
42
+
43
+ following = source.getbyte(offset + 1)
44
+ following && ![9, 10, 13, 32].include?(following)
45
+ end
46
+ end
47
+
48
+ Registry.register(UnnecessaryQuotes)
49
+ end
50
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yamlfmt
4
+ class RulePlan
5
+ def call(config)
6
+ Rule::Registry.rules.filter_map do |rule_class|
7
+ options = config.options_for(rule_class)
8
+ rule_class.new(options) if options
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yamlfmt
4
+ class SafetyValidator
5
+ PERMITTED_CLASSES = [Date, Time, Symbol].freeze
6
+
7
+ def call(original_document, candidate_source)
8
+ candidate = Document.new(candidate_source, path: original_document.path)
9
+ validate_values!(original_document, candidate)
10
+ validate_comments!(original_document, candidate)
11
+ candidate
12
+ rescue ParseError, UnsupportedFileError => error
13
+ raise ValidationError, "formatted source could not be parsed safely: #{error.message}"
14
+ end
15
+
16
+ private
17
+
18
+ def validate_values!(original, candidate)
19
+ original_values = load_values(original)
20
+ candidate_values = load_values(candidate)
21
+ return if equivalent?(original_values, candidate_values)
22
+
23
+ raise ValidationError, "formatting changed the YAML value"
24
+ rescue Psych::Exception => error
25
+ raise ValidationError, "YAML value could not be loaded safely: #{error.message}"
26
+ end
27
+
28
+ def validate_comments!(original, candidate)
29
+ return if original.comment_values == candidate.comment_values
30
+
31
+ raise ValidationError, "formatting changed YAML comments"
32
+ end
33
+
34
+ def load_values(document)
35
+ Psych.safe_load_stream(
36
+ document.source,
37
+ filename: document.path,
38
+ permitted_classes: PERMITTED_CLASSES,
39
+ aliases: true
40
+ )
41
+ end
42
+
43
+ def equivalent?(left, right, seen = {})
44
+ return true if left.equal?(right)
45
+ return false unless left.instance_of?(right.class)
46
+
47
+ pair = [left.object_id, right.object_id]
48
+ return true if seen[pair]
49
+
50
+ seen[pair] = true
51
+
52
+ case left
53
+ when Array
54
+ left.length == right.length && left.zip(right).all? { |left_item, right_item| equivalent?(left_item, right_item, seen) }
55
+ when Hash
56
+ equivalent_hash?(left, right, seen)
57
+ when Float
58
+ (left.nan? && right.nan?) || left == right
59
+ else
60
+ left == right
61
+ end
62
+ end
63
+
64
+ def equivalent_hash?(left, right, seen)
65
+ return false unless left.length == right.length
66
+
67
+ left.to_a.zip(right.to_a).all? do |(left_key, left_value), (right_key, right_value)|
68
+ equivalent?(left_key, right_key, seen) && equivalent?(left_value, right_value, seen)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yamlfmt
4
+ SourceRange = Data.define(:start_offset, :end_offset) do
5
+ def initialize(start_offset:, end_offset:)
6
+ unless start_offset.is_a?(Integer) && end_offset.is_a?(Integer) &&
7
+ start_offset >= 0 && end_offset >= start_offset
8
+ raise ArgumentError, "source range must be a non-negative half-open byte range"
9
+ end
10
+
11
+ super
12
+ end
13
+
14
+ def length
15
+ end_offset - start_offset
16
+ end
17
+
18
+ def empty?
19
+ length.zero?
20
+ end
21
+
22
+ def cover?(other)
23
+ start_offset <= other.start_offset && end_offset >= other.end_offset
24
+ end
25
+
26
+ def overlaps?(other)
27
+ if empty? && other.empty?
28
+ start_offset == other.start_offset
29
+ elsif empty?
30
+ start_offset > other.start_offset && start_offset < other.end_offset
31
+ elsif other.empty?
32
+ other.start_offset > start_offset && other.start_offset < end_offset
33
+ else
34
+ start_offset < other.end_offset && other.start_offset < end_offset
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yamlfmt
4
+ class UnifiedDiff
5
+ CONTEXT_LINES = 3
6
+ MAX_LCS_CELLS = 1_000_000
7
+
8
+ Record = Data.define(:type, :line)
9
+
10
+ def call(before, after, path:)
11
+ return "" if before == after
12
+
13
+ records = diff_records(split_lines(before), split_lines(after))
14
+ hunks = hunk_ranges(records)
15
+
16
+ output = "--- #{path}\n+++ #{path}\n"
17
+ hunks.each { |range| append_hunk(output, records, range) }
18
+ output
19
+ end
20
+
21
+ private
22
+
23
+ def split_lines(source)
24
+ source.lines(chomp: false)
25
+ end
26
+
27
+ def diff_records(before, after)
28
+ return fallback_records(before, after) if before.length * after.length > MAX_LCS_CELLS
29
+
30
+ table = lcs_table(before, after)
31
+ records = []
32
+ before_index = 0
33
+ after_index = 0
34
+
35
+ while before_index < before.length && after_index < after.length
36
+ if before[before_index] == after[after_index]
37
+ records << Record.new(type: :equal, line: before[before_index])
38
+ before_index += 1
39
+ after_index += 1
40
+ elsif table[before_index + 1][after_index] >= table[before_index][after_index + 1]
41
+ records << Record.new(type: :delete, line: before[before_index])
42
+ before_index += 1
43
+ else
44
+ records << Record.new(type: :insert, line: after[after_index])
45
+ after_index += 1
46
+ end
47
+ end
48
+
49
+ before.drop(before_index).each { |line| records << Record.new(type: :delete, line:) }
50
+ after.drop(after_index).each { |line| records << Record.new(type: :insert, line:) }
51
+ records
52
+ end
53
+
54
+ def lcs_table(before, after)
55
+ table = Array.new(before.length + 1) { Array.new(after.length + 1, 0) }
56
+
57
+ (before.length - 1).downto(0) do |before_index|
58
+ (after.length - 1).downto(0) do |after_index|
59
+ table[before_index][after_index] = if before[before_index] == after[after_index]
60
+ table[before_index + 1][after_index + 1] + 1
61
+ else
62
+ [table[before_index + 1][after_index], table[before_index][after_index + 1]].max
63
+ end
64
+ end
65
+ end
66
+
67
+ table
68
+ end
69
+
70
+ def fallback_records(before, after)
71
+ prefix_length = common_prefix_length(before, after)
72
+ suffix_length = common_suffix_length(before.drop(prefix_length), after.drop(prefix_length))
73
+
74
+ prefix = before.first(prefix_length).map { |line| Record.new(type: :equal, line:) }
75
+ deleted = before[prefix_length, before.length - prefix_length - suffix_length].map { |line| Record.new(type: :delete, line:) }
76
+ inserted = after[prefix_length, after.length - prefix_length - suffix_length].map { |line| Record.new(type: :insert, line:) }
77
+ suffix = before.last(suffix_length).map { |line| Record.new(type: :equal, line:) }
78
+ prefix + deleted + inserted + suffix
79
+ end
80
+
81
+ def common_prefix_length(before, after)
82
+ limit = [before.length, after.length].min
83
+ (0...limit).find { |index| before[index] != after[index] } || limit
84
+ end
85
+
86
+ def common_suffix_length(before, after)
87
+ limit = [before.length, after.length].min
88
+ (0...limit).find { |index| before[-index - 1] != after[-index - 1] } || limit
89
+ end
90
+
91
+ def hunk_ranges(records)
92
+ changes = records.each_index.reject { |index| records[index].type == :equal }
93
+ ranges = changes.map do |index|
94
+ [index - CONTEXT_LINES, 0].max..[index + CONTEXT_LINES, records.length - 1].min
95
+ end
96
+
97
+ ranges.each_with_object([]) do |range, merged|
98
+ if merged.last && range.begin <= merged.last.end + 1
99
+ merged[-1] = merged.last.begin..[merged.last.end, range.end].max
100
+ else
101
+ merged << range
102
+ end
103
+ end
104
+ end
105
+
106
+ def append_hunk(output, records, range)
107
+ before_start = 1 + records.first(range.begin).count { |record| record.type != :insert }
108
+ after_start = 1 + records.first(range.begin).count { |record| record.type != :delete }
109
+ hunk = records[range]
110
+ before_count = hunk.count { |record| record.type != :insert }
111
+ after_count = hunk.count { |record| record.type != :delete }
112
+ before_start -= 1 if before_count.zero?
113
+ after_start -= 1 if after_count.zero?
114
+
115
+ output << "@@ -#{before_start},#{before_count} +#{after_start},#{after_count} @@\n"
116
+ hunk.each { |record| append_record(output, record) }
117
+ end
118
+
119
+ def append_record(output, record)
120
+ prefix = {equal: " ", delete: "-", insert: "+"}.fetch(record.type)
121
+ terminated = record.line.end_with?("\n", "\r")
122
+ content = record.line.delete_suffix("\n")
123
+ output << prefix << content << "\n"
124
+ output << "\\n" unless terminated
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yamlfmt
4
+ VERSION = "0.1.0"
5
+ end
data/lib/yamlfmt.rb ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "yamlfmt/version"
4
+ require_relative "yamlfmt/errors"
5
+ require_relative "yamlfmt/source_range"
6
+ require_relative "yamlfmt/edit"
7
+ require_relative "yamlfmt/finding"
8
+ require_relative "yamlfmt/config"
9
+ require_relative "yamlfmt/exclude_matcher"
10
+ require_relative "yamlfmt/file_finder"
11
+ require_relative "yamlfmt/document"
12
+ require_relative "yamlfmt/corrector"
13
+ require_relative "yamlfmt/safety_validator"
14
+ require_relative "yamlfmt/processor"
15
+ require_relative "yamlfmt/unified_diff"
16
+ require_relative "yamlfmt/rule/base"
17
+ require_relative "yamlfmt/rule/registry"
18
+ require_relative "yamlfmt/rule/line_based"
19
+ require_relative "yamlfmt/rule/ast_based"
20
+ require_relative "yamlfmt/rule/trailing_whitespace"
21
+ require_relative "yamlfmt/rule/blank_lines"
22
+ require_relative "yamlfmt/rule/final_newline"
23
+ require_relative "yamlfmt/rule/unnecessary_quotes"
24
+ require_relative "yamlfmt/rule_plan"
25
+ require_relative "yamlfmt/cli"
26
+
27
+ module Yamlfmt
28
+ end
metadata ADDED
@@ -0,0 +1,112 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: yamlfmt
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - zeronosu77108
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-09-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: psych
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 5.2.5
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '6'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: 5.2.5
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '6'
33
+ - !ruby/object:Gem::Dependency
34
+ name: psych-pure
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 0.3.0
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 0.3.0
47
+ description: Applies targeted edits while preserving comments and untouched formatting.
48
+ email:
49
+ - mail@zeronosu77108.com
50
+ executables:
51
+ - yamlfmt
52
+ extensions: []
53
+ extra_rdoc_files: []
54
+ files:
55
+ - ".ruby-version"
56
+ - ".standard.yml"
57
+ - LICENSE
58
+ - README.md
59
+ - Rakefile
60
+ - exe/yamlfmt
61
+ - lib/yamlfmt.rb
62
+ - lib/yamlfmt/cli.rb
63
+ - lib/yamlfmt/config.rb
64
+ - lib/yamlfmt/corrector.rb
65
+ - lib/yamlfmt/document.rb
66
+ - lib/yamlfmt/edit.rb
67
+ - lib/yamlfmt/errors.rb
68
+ - lib/yamlfmt/exclude_matcher.rb
69
+ - lib/yamlfmt/file_finder.rb
70
+ - lib/yamlfmt/finding.rb
71
+ - lib/yamlfmt/processor.rb
72
+ - lib/yamlfmt/rule/ast_based.rb
73
+ - lib/yamlfmt/rule/base.rb
74
+ - lib/yamlfmt/rule/blank_lines.rb
75
+ - lib/yamlfmt/rule/final_newline.rb
76
+ - lib/yamlfmt/rule/line_based.rb
77
+ - lib/yamlfmt/rule/registry.rb
78
+ - lib/yamlfmt/rule/trailing_whitespace.rb
79
+ - lib/yamlfmt/rule/unnecessary_quotes.rb
80
+ - lib/yamlfmt/rule_plan.rb
81
+ - lib/yamlfmt/safety_validator.rb
82
+ - lib/yamlfmt/source_range.rb
83
+ - lib/yamlfmt/unified_diff.rb
84
+ - lib/yamlfmt/version.rb
85
+ homepage: https://github.com/zeronosu77108/yamlfmt
86
+ licenses:
87
+ - MIT
88
+ metadata:
89
+ allowed_push_host: https://rubygems.org
90
+ homepage_uri: https://github.com/zeronosu77108/yamlfmt
91
+ source_code_uri: https://github.com/zeronosu77108/yamlfmt
92
+ rubygems_mfa_required: 'true'
93
+ post_install_message:
94
+ rdoc_options: []
95
+ require_paths:
96
+ - lib
97
+ required_ruby_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '3.3'
102
+ required_rubygems_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ requirements: []
108
+ rubygems_version: 3.5.16
109
+ signing_key:
110
+ specification_version: 4
111
+ summary: A comment-preserving, minimal-diff YAML formatter for Ruby
112
+ test_files: []