rubocop-spellcheck 0.1.0 → 0.2.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,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rdoc'
4
+ require 'rdoc/markup'
5
+ require 'rdoc/markup/inline_parser'
6
+
7
+ module RuboCop
8
+ module Spellcheck
9
+ # Reads RDoc prose without checking verbatim examples or inline code.
10
+ class RDoc
11
+ # Records the original tokens consumed by paragraph and heading parsing.
12
+ class Parser < ::RDoc::Markup::Parser
13
+ attr_reader :prose
14
+
15
+ def initialize
16
+ super
17
+ @prose = {}
18
+ end
19
+
20
+ def build_paragraph(margin)
21
+ @margin = margin
22
+ super
23
+ ensure
24
+ @margin = nil
25
+ end
26
+
27
+ def build_heading(level)
28
+ @heading = true
29
+ super
30
+ ensure
31
+ @heading = false
32
+ end
33
+
34
+ def get
35
+ token = super
36
+ type, text, column, line = token
37
+ @prose[[line, column]] = text.dup if type == :TEXT && (@heading || column == @margin)
38
+ token
39
+ end
40
+ end
41
+
42
+ # Uses consumed inline token ranges to retain original source positions.
43
+ class Inline < ::RDoc::Markup::InlineParser
44
+ def initialize(source)
45
+ super
46
+ @source = source
47
+ @masked = source.dup
48
+ end
49
+
50
+ def prose
51
+ parse
52
+ @masked.gsub(/(?:link:|rdoc-(?:ref|label|image):)\S+/) { |link| ' ' * link.length }
53
+ end
54
+
55
+ private
56
+
57
+ def scan_token
58
+ first = @scanner.pos
59
+ result = super
60
+ raw = @source.byteslice(first...@scanner.pos)
61
+ offset = @source.byteslice(0...first).length
62
+ mask_token(result, raw, offset)
63
+ result
64
+ end
65
+
66
+ def mask_token(result, raw, offset)
67
+ type, _token, node = result
68
+ case type
69
+ when :node
70
+ mask_node(node, raw, offset)
71
+ when :open, :close, :tidylink_open, :tidylink_close, :invalidated_tidylink_close
72
+ mask(offset, raw.length)
73
+ end
74
+ end
75
+
76
+ def mask_node(node, raw, offset)
77
+ return mask(offset, raw.length) if %i[TT HARD_BREAK].include?(node[:type])
78
+
79
+ text = node[:children].join
80
+ first = raw.index(text)
81
+ mask(offset, first)
82
+ mask(offset + first + text.length, raw.length - first - text.length)
83
+ end
84
+
85
+ def mask(first, length)
86
+ @masked[first, length] = @masked[first, length].gsub(/[^\n]/, ' ')
87
+ end
88
+ end
89
+
90
+ def self.file?(path)
91
+ path && File.extname(path).casecmp?('.rdoc')
92
+ end
93
+
94
+ def initialize(buffer, source: buffer.source, positions: nil)
95
+ @buffer = buffer
96
+ @source = source
97
+ @positions = positions || source.length.times.map { |index| [index, index + 1] }
98
+ end
99
+
100
+ def each_span
101
+ return enum_for(__method__) unless block_given?
102
+
103
+ yield Span.new(Inline.new(prose_source).prose, @positions)
104
+ rescue ::RDoc::Markup::Parser::ParseError => e
105
+ raise RuboCop::ValidationError, "Invalid RDoc in #{@buffer.name}: #{e.message}"
106
+ end
107
+
108
+ private
109
+
110
+ def prose_source
111
+ source = @source.gsub(/[^\n]/, ' ')
112
+ offsets = line_offsets
113
+ prose.each do |(line, column), text|
114
+ source[offsets.fetch(line) + column, text.length] = text
115
+ end
116
+ source
117
+ end
118
+
119
+ def prose
120
+ parser = Parser.new
121
+ parser.tokenize(mask_directives)
122
+ parser.parse(::RDoc::Markup::Document.new)
123
+ parser.prose
124
+ end
125
+
126
+ def mask_directives
127
+ @source.gsub(/^[ \t]*:[a-z][\w-]*:.*$/i) { |text| ' ' * text.length }
128
+ end
129
+
130
+ def line_offsets
131
+ offset = 0
132
+ @source.lines.map do |line|
133
+ first = offset
134
+ offset += line.length
135
+ first
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Spellcheck
5
+ # Combines adjacent Ruby comment lines before parsing their RDoc structure.
6
+ class RDocComments
7
+ def initialize(buffer, comments)
8
+ @buffer = buffer
9
+ @comments = comments
10
+ end
11
+
12
+ def each_span(&block)
13
+ return enum_for(__method__) unless block_given?
14
+
15
+ groups.each do |comments|
16
+ source, positions = comment_source(comments)
17
+ RDoc.new(@buffer, source: source, positions: positions).each_span(&block)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def groups
24
+ @comments.slice_when do |previous, current|
25
+ current.loc.line != previous.loc.last_line + 1 || current.text.start_with?('=begin') ||
26
+ previous.text.start_with?('=begin')
27
+ end
28
+ end
29
+
30
+ def comment_source(comments)
31
+ source = +''
32
+ positions = []
33
+ comments.each do |comment|
34
+ text, offsets = mapped_comment(comment)
35
+ source << text
36
+ positions.concat(offsets)
37
+ source << "\n"
38
+ positions << [comment.source_range.end_pos, comment.source_range.end_pos]
39
+ end
40
+ [source, positions]
41
+ end
42
+
43
+ def mapped_comment(comment)
44
+ first = comment.source_range.begin_pos
45
+ pieces = comment.text.lines.map do |line|
46
+ text, removed = strip_marker(line)
47
+ offsets = positions(first + removed, text.length)
48
+ first += line.length
49
+ [text, offsets]
50
+ end
51
+ [pieces.map(&:first).join, pieces.flat_map(&:last)]
52
+ end
53
+
54
+ def positions(first, length)
55
+ length.times.map { |index| [first + index, first + index + 1] }
56
+ end
57
+
58
+ def strip_marker(line)
59
+ return [line.gsub(/[^\n]/, ' '), 0] if line.match?(/\A=(?:begin|end)\b/)
60
+
61
+ marker = line[/\A# ?/].to_s.length
62
+ [line[marker..], marker]
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Spellcheck
5
+ # Applies independent Ruby scope settings to source-aware extractors.
6
+ class RubyCode
7
+ DIRECTIVE = /
8
+ \A\#\s*(?:!|(?:rubocop|standard|coding|encoding|frozen_string_literal|
9
+ warn_indent|shareable_constant_value|typed)\s*:)|\A\#.*-\*-\s*(?:coding|encoding):
10
+ /x
11
+
12
+ def initialize(source, config: {})
13
+ valid = config.is_a?(Hash) &&
14
+ %w[Strings Identifiers].all? { |key| [true, false].include?(config.fetch(key, true)) }
15
+ unless valid
16
+ raise RuboCop::ValidationError, 'Spellcheck/Spelling Ruby Strings and Identifiers must be true or false.'
17
+ end
18
+
19
+ @source = source
20
+ @config = config
21
+ end
22
+
23
+ def each_span(&)
24
+ comments = @source.comments.reject { |comment| DIRECTIVE.match?(comment.text) }
25
+ RDocComments.new(@source.buffer, comments).each_span(&)
26
+ each_code_span(&)
27
+ end
28
+
29
+ def each_code_span(&)
30
+ return unless @source.ast
31
+
32
+ RubyStrings.new(@source).each_span(&) if @config.fetch('Strings', true)
33
+ RubyIdentifiers.new(@source).each_span(&) if @config.fetch('Identifiers', false)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Spellcheck
5
+ # Splits Ruby names without treating symbols, keywords, or operators as identifiers.
6
+ class RubyIdentifiers
7
+ # Identifier occurrences cannot be renamed independently of their references.
8
+ class Span < Spellcheck::Span
9
+ def correction(word, suggestions)
10
+ Correction.new(word, suggestions)
11
+ end
12
+ end
13
+
14
+ # Keeps diagnostics and suggestions without offering identifier replacements.
15
+ class Correction < Spellcheck::Correction
16
+ def replacement; end
17
+ end
18
+
19
+ TYPES = %i[tIDENTIFIER tFID tCONSTANT tIVAR tCVAR tGVAR].freeze
20
+ BOUNDARY = /(?<=\p{Ll})(?=\p{Lu})|(?<=\p{Lu})(?=\p{Lu}\p{Ll})/
21
+
22
+ def initialize(source)
23
+ @source = source
24
+ @parameters = source.ast.each_node(:kwarg, :kwoptarg).to_set { |node| node.loc.name.begin_pos }
25
+ @tokens = source.tokens.select { |token| identifier?(token) }
26
+ end
27
+
28
+ def each_span(&block)
29
+ return enum_for(__method__) unless block_given?
30
+
31
+ @tokens.each do |token|
32
+ token.text.to_enum(:scan, /[\p{L}\p{M}]+/).each do
33
+ match = Regexp.last_match
34
+ split(match[0], token.pos.begin_pos + match.begin(0), &block)
35
+ end
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def identifier?(token)
42
+ TYPES.include?(token.type) || (token.type == :tLABEL && @parameters.include?(token.pos.begin_pos))
43
+ end
44
+
45
+ def split(name, first)
46
+ name.split(BOUNDARY).each do |word|
47
+ positions = word.length.times.map { |index| [first + index, first + index + 1] }
48
+ yield Span.new(word, positions)
49
+ first += word.length
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Spellcheck
5
+ # Uses Ruby's parser, never evaluation, to decode escape sequences and retain source ranges.
6
+ class RubyStringMap
7
+ ESCAPE = /\\(?:u\{[0-9a-fA-F\s]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{1,2}|
8
+ [0-7]{1,3}|(?:M-|C-|c)(?:\\(?:M-|C-|c))*\\?.|.)/mx
9
+ PIECE = /#{ESCAPE}|./m
10
+
11
+ def initialize(version)
12
+ @version = version
13
+ @escapes = {}
14
+ end
15
+
16
+ def positions(raw, first, expected, single:)
17
+ decoded, bytes = decoded_bytes(raw, first, single)
18
+ bytes = without_indentation(decoded, bytes, expected.b)
19
+ character_positions(expected, bytes)
20
+ end
21
+
22
+ private
23
+
24
+ def decoded_bytes(raw, first, single)
25
+ decoded = +''.b
26
+ bytes = []
27
+ raw.scan(pattern(single)) do |piece|
28
+ match = Regexp.last_match
29
+ piece = decode(piece, single)
30
+ decoded << piece.b
31
+ piece.bytesize.times { bytes << [first + match.begin(0), first + match.end(0)] }
32
+ end
33
+ [decoded, bytes]
34
+ end
35
+
36
+ def pattern(single)
37
+ return PIECE unless single
38
+ return /./m if single.empty?
39
+
40
+ /\\[#{Regexp.escape(single)}]|./m
41
+ end
42
+
43
+ def decode(piece, single)
44
+ return piece unless piece.start_with?('\\') && piece.length > 1
45
+ return piece.delete_prefix('\\') if single&.include?(piece[1])
46
+ return piece if single
47
+
48
+ @escapes[piece] ||= parsed_escape(piece)
49
+ end
50
+
51
+ def parsed_escape(piece)
52
+ source = RuboCop::ProcessedSource.new("\"#{piece}\"", @version)
53
+ return source.ast.value if source.valid_syntax? && source.ast&.str_type?
54
+
55
+ raise RuboCop::ValidationError, "Cannot map Ruby string escape #{piece.inspect}."
56
+ end
57
+
58
+ def without_indentation(decoded, bytes, expected)
59
+ return bytes if decoded == expected
60
+
61
+ offset = 0
62
+ lines = expected.lines
63
+ decoded.lines.flat_map.with_index do |line, index|
64
+ target = lines.fetch(index, '')
65
+ removed = indentation(line, target)
66
+ mapped = bytes.slice(offset + removed, target.bytesize)
67
+ offset += line.bytesize
68
+ mapped
69
+ end
70
+ end
71
+
72
+ def indentation(line, target)
73
+ removed = line.bytesize - target.bytesize
74
+ return removed if removed >= 0 && line.end_with?(target) && line.byteslice(0, removed).match?(/\A[ \t]*\z/)
75
+
76
+ raise RuboCop::ValidationError, "Cannot map decoded Ruby string #{target.inspect}."
77
+ end
78
+
79
+ def character_positions(text, bytes)
80
+ offset = 0
81
+ text.each_char.map do |character|
82
+ range = [bytes.fetch(offset).first, bytes.fetch(offset + character.bytesize - 1).last]
83
+ offset += character.bytesize
84
+ range
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Spellcheck
5
+ # Extracts string tokens selected by the syntax tree, excluding symbols and executable strings.
6
+ class RubyStrings
7
+ PATH_ARGUMENTS = { require: 0, require_relative: 0, load: 0, autoload: 1 }.freeze
8
+
9
+ # Retains delimiters and heredoc boundaries for source-safe replacement.
10
+ class Span < Spellcheck::Span
11
+ def initialize(text, positions, delimiters, terminator)
12
+ super(text, positions)
13
+ @delimiters = delimiters
14
+ @terminator = terminator
15
+ end
16
+
17
+ def correction(word, suggestions)
18
+ Correction.new(word, suggestions, @delimiters, @terminator)
19
+ end
20
+ end
21
+
22
+ # Escapes replacement delimiters and never introduces a heredoc terminator.
23
+ class Correction < Spellcheck::Correction
24
+ def initialize(word, suggestions, delimiters, terminator)
25
+ super(word, suggestions)
26
+ @delimiters = delimiters
27
+ @terminator = terminator
28
+ end
29
+
30
+ def replacement
31
+ result = super
32
+ return unless result && result != @terminator
33
+
34
+ result.each_char.map { |character| @delimiters.include?(character) ? "\\#{character}" : character }.join
35
+ end
36
+ end
37
+
38
+ def initialize(source)
39
+ @source = source
40
+ @map = RubyStringMap.new(source.ruby_version)
41
+ @tokens = source.tokens.select { |token| %i[tSTRING tSTRING_CONTENT tCHARACTER].include?(token.type) }
42
+ .sort_by { |token| token.pos.begin_pos }
43
+ end
44
+
45
+ def each_span
46
+ return enum_for(__method__) unless block_given?
47
+
48
+ @source.ast.each_node(:str) do |node|
49
+ next if excluded?(node)
50
+
51
+ range = content_range(node)
52
+ tokens_in(range).each { |token| yield mapped_span(node, token, range) }
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def mapped_span(node, token, range)
59
+ raw = token.type == :tSTRING_CONTENT ? token.pos : range
60
+ positions = @map.positions(raw.source, raw.begin_pos, token.text, single: single_escapes(node))
61
+ text = token.text.each_char.map { |character| character.valid_encoding? ? character : ' ' }.join
62
+ Span.new(text, positions, delimiters(node), terminator(node))
63
+ end
64
+
65
+ def tokens_in(range)
66
+ first = @tokens.bsearch_index { |token| token.pos.end_pos > range.begin_pos }
67
+ return [] unless first
68
+
69
+ last = @tokens.bsearch_index { |token| token.pos.begin_pos >= range.end_pos } || @tokens.length
70
+ @tokens[first...last]
71
+ end
72
+
73
+ def excluded?(node)
74
+ return true unless node.heredoc? || node.loc.respond_to?(:begin) # __FILE__ has no literal delimiters.
75
+ return true if dependency_string?(node)
76
+
77
+ node.each_ancestor do |ancestor|
78
+ return false if ancestor.begin_type?
79
+ return true if %i[regexp dsym xstr].include?(ancestor.type)
80
+ end
81
+ false
82
+ end
83
+
84
+ def dependency_string?(node)
85
+ node = node.parent while node.parent && %i[dstr array].include?(node.parent.type)
86
+ call = node.parent
87
+ return false unless call&.send_type?
88
+
89
+ dependency_argument?(call, node) &&
90
+ (call.method?(:autoload) ? module_receiver?(call.receiver) : kernel_receiver?(call.receiver))
91
+ end
92
+
93
+ def dependency_argument?(call, node)
94
+ return call.arguments.include?(node) if call.method?(:gem)
95
+ return false unless PATH_ARGUMENTS.key?(call.method_name)
96
+
97
+ call.arguments[PATH_ARGUMENTS.fetch(call.method_name)].equal?(node)
98
+ end
99
+
100
+ def module_receiver?(receiver)
101
+ receiver.nil? || receiver.self_type? || receiver.const_type?
102
+ end
103
+
104
+ def kernel_receiver?(receiver)
105
+ receiver.nil? || receiver.self_type? ||
106
+ (receiver.const_type? && %w[Kernel ::Kernel].include?(receiver.const_name))
107
+ end
108
+
109
+ def content_range(node)
110
+ return node.loc.heredoc_body if node.heredoc?
111
+
112
+ location = node.loc
113
+ range = location.expression
114
+ range.with(begin_pos: location.begin&.end_pos || range.begin_pos,
115
+ end_pos: location.end&.begin_pos || range.end_pos)
116
+ end
117
+
118
+ def single_escapes(node)
119
+ opening = literal_opening(node)
120
+ return '' if opening.match?(/\A<<[-~]?'/)
121
+ return "\\'" if opening == "'"
122
+ return unless opening.start_with?('%q', '%w')
123
+
124
+ whitespace = opening.start_with?('%w') ? " \t\r\n" : ''
125
+ "\\#{whitespace}#{paired_delimiters(opening[-1])}"
126
+ end
127
+
128
+ def delimiters(node)
129
+ opening = literal_opening(node)
130
+ return '' if opening.start_with?('<<')
131
+
132
+ paired_delimiters(opening[-1])
133
+ end
134
+
135
+ def paired_delimiters(delimiter)
136
+ return '' unless delimiter
137
+
138
+ "#{delimiter}#{{ '(' => ')', '[' => ']', '{' => '}', '<' => '>' }.fetch(delimiter, delimiter)}"
139
+ end
140
+
141
+ def terminator(node)
142
+ container = node.parent&.dstr_type? ? node.parent : node
143
+ container.loc.heredoc_end.source.strip if container.heredoc?
144
+ end
145
+
146
+ def literal_opening(node)
147
+ return node.loc.expression.source if node.heredoc?
148
+ return node.loc.begin.source if node.loc.begin
149
+
150
+ parent = node.parent
151
+ return literal_opening(parent) if parent&.dstr_type?
152
+
153
+ parent_opening(parent)
154
+ end
155
+
156
+ def parent_opening(parent)
157
+ return '' unless parent&.loc.respond_to?(:begin)
158
+
159
+ parent.loc.begin&.source || ''
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Spellcheck
5
+ # Associates extracted prose with its original positions and correction policy.
6
+ class Span
7
+ attr_reader :text, :positions
8
+
9
+ def initialize(text, positions)
10
+ @text = text
11
+ @positions = positions
12
+ end
13
+
14
+ def correction(word, suggestions)
15
+ Correction.new(word, suggestions)
16
+ end
17
+
18
+ def each_misspelling(checker, text)
19
+ checker.misspellings(text).each do |word, position|
20
+ next unless word.match?(/\p{L}/)
21
+
22
+ whole = Correction.whole_word?(positions, position, word.length) &&
23
+ Correction.standalone?(text, position, word.length)
24
+ yield word, *range(position, word.length), correction(word, checker.suggestions(word)), whole
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def range(position, length)
31
+ [positions.fetch(position).first, positions.fetch(position + length - 1).last]
32
+ end
33
+ end
34
+ end
35
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module Spellcheck
5
- VERSION = '0.1.0'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Spellcheck
5
+ # Rebuilds vocabulary from current violations while retaining comments and needed word spelling.
6
+ class Vocabulary
7
+ # Still fingerprints and excludes the vocabulary file, but does not accept its words.
8
+ class Checker < Spellcheck::Checker
9
+ def suggestions(_word)
10
+ # Vocabulary generation only collects words, so skip expensive suggestions.
11
+ []
12
+ end
13
+
14
+ private
15
+
16
+ def dictionary_word(line, path)
17
+ super unless path == File.join(@base_directory, '.spellcheck.txt')
18
+ end
19
+ end
20
+
21
+ def initialize(config)
22
+ @config = config
23
+ @path = File.join(config.base_dir_for_path_parameters, '.spellcheck.txt')
24
+ @words = Set.new
25
+ end
26
+
27
+ def add(offense)
28
+ word = offense.message[/Misspelled word: `([^`]+)`\./, 1]
29
+ unless word
30
+ raise RuboCop::ValidationError, "Cannot extract spelling vocabulary from #{offense.message.inspect}."
31
+ end
32
+
33
+ @words << word.downcase
34
+ end
35
+
36
+ def write
37
+ content = File.exist?(@path) ? File.read(@path, encoding: 'UTF-8') : ''
38
+ additions = (@words - existing_words(content)).sort
39
+ config = VocabularyConfig.new(@config.loaded_path, section: section).updated(dictionaries)
40
+ updated = extended(retained(content), additions)
41
+ File.write(@path, updated) unless content == updated
42
+ write_configuration(config)
43
+ puts "Updated #{RuboCop::PathUtil.relative_path(@path)}. " \
44
+ 'Review generated vocabulary: reported typos are accepted too.'
45
+ end
46
+
47
+ private
48
+
49
+ def retained(content)
50
+ content.lines.select do |line|
51
+ word = line.strip
52
+ word.empty? || word.start_with?('#') || @words.include?(word.downcase)
53
+ end.join
54
+ end
55
+
56
+ def write_configuration(content)
57
+ File.write(@config.loaded_path, content) unless File.read(@config.loaded_path, encoding: 'UTF-8') == content
58
+ end
59
+
60
+ def dictionaries
61
+ badge = RuboCop::Cop::Badge.parse(VocabularyConfig::COP)
62
+ files = @config.for_badge(badge)['DictionaryFiles'] || Checker::DEFAULT_FILES
63
+ files.transform_values do |paths|
64
+ paths.any? { |path| vocabulary_path?(path) } ? paths : [*paths, '.spellcheck.txt']
65
+ end
66
+ end
67
+
68
+ def vocabulary_path?(path)
69
+ !path.start_with?('gem:') && File.expand_path(path, @config.base_dir_for_path_parameters) == @path
70
+ end
71
+
72
+ def section
73
+ @config.for_cop(VocabularyConfig::COP).key?('DictionaryFiles') ? VocabularyConfig::COP : 'Spellcheck'
74
+ end
75
+
76
+ def existing_words(content)
77
+ content.lines.filter_map do |line|
78
+ word = line.strip
79
+ next if word.empty? || word.start_with?('#')
80
+ raise RuboCop::ValidationError, "Expected one word per line in #{@path}: #{word.inspect}" if word.match?(/\s/)
81
+
82
+ word.downcase
83
+ end.to_set
84
+ end
85
+
86
+ def extended(content, additions)
87
+ return content if additions.empty?
88
+
89
+ separator = content.empty? || content.end_with?("\n") ? '' : "\n"
90
+ "#{content}#{separator}#{additions.join("\n")}\n"
91
+ end
92
+ end
93
+ end
94
+ end