semverve 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,427 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+
5
+ require_relative "error"
6
+ require_relative "file_list_resolver"
7
+ require_relative "semantic_version"
8
+
9
+ module Semverve
10
+ ##
11
+ # Finds and updates version references in configured project files.
12
+ class VersionReferences
13
+ ##
14
+ # Marker that suppresses version-reference findings.
15
+ #
16
+ # @return [String]
17
+ IGNORE_MARKER = "semverve:ignore-version-reference"
18
+
19
+ ##
20
+ # Version strings supported by Semverve.
21
+ #
22
+ # @return [Regexp]
23
+ VERSION_PATTERN = /(?<![\d.])\d+\.\d+\.\d+(?!\.\d|\d)/
24
+
25
+ ##
26
+ # Text file extensions scanned as full content.
27
+ #
28
+ # @return [Array<String>]
29
+ TEXT_EXTENSIONS = %w[.adoc .markdown .md .rdoc .txt].freeze
30
+
31
+ ##
32
+ # A stale or non-current version reference found in a project file.
33
+ class Finding
34
+ ##
35
+ # Path relative to the configured project root.
36
+ #
37
+ # @return [String]
38
+ attr_reader :path
39
+
40
+ ##
41
+ # One-based line number.
42
+ #
43
+ # @return [Integer]
44
+ attr_reader :line
45
+
46
+ ##
47
+ # One-based column number.
48
+ #
49
+ # @return [Integer]
50
+ attr_reader :column
51
+
52
+ ##
53
+ # Referenced semantic version.
54
+ #
55
+ # @return [Semverve::SemanticVersion]
56
+ attr_reader :version
57
+
58
+ ##
59
+ # Initializes a finding.
60
+ #
61
+ # @param [String] path
62
+ # @param [Integer] line
63
+ # @param [Integer] column
64
+ # @param [Semverve::SemanticVersion] version
65
+ #
66
+ # @return [Semverve::VersionReferences::Finding]
67
+ def initialize(path:, line:, column:, version:)
68
+ @path = path
69
+ @line = line
70
+ @column = column
71
+ @version = version
72
+ end
73
+ end
74
+
75
+ ##
76
+ # Result of fixing version references.
77
+ class FixResult
78
+ ##
79
+ # Files changed by the fix.
80
+ #
81
+ # @return [Array<String>]
82
+ attr_reader :changed_files
83
+
84
+ ##
85
+ # Number of replacements made.
86
+ #
87
+ # @return [Integer]
88
+ attr_reader :replacement_count
89
+
90
+ ##
91
+ # Initializes a fix result.
92
+ #
93
+ # @param [Array<String>] changed_files
94
+ # @param [Integer] replacement_count
95
+ #
96
+ # @return [Semverve::VersionReferences::FixResult]
97
+ def initialize(changed_files:, replacement_count:)
98
+ @changed_files = changed_files
99
+ @replacement_count = replacement_count
100
+ end
101
+ end
102
+
103
+ ##
104
+ # Initializes version-reference scanning.
105
+ #
106
+ # @param [Semverve::ResolvedConfiguration] configuration
107
+ # @param [Semverve::SemanticVersion] current_version
108
+ #
109
+ # @return [Semverve::VersionReferences]
110
+ def initialize(configuration, current_version)
111
+ @configuration = configuration
112
+ @current_version = current_version
113
+ end
114
+
115
+ ##
116
+ # Version-reference findings in configured files.
117
+ #
118
+ # @return [Array<Semverve::VersionReferences::Finding>]
119
+ def findings
120
+ files.flat_map { |path| findings_for_file(path) }
121
+ end
122
+
123
+ ##
124
+ # Replaces found version references with the current version.
125
+ #
126
+ # @return [Semverve::VersionReferences::FixResult]
127
+ def fix
128
+ changed_files = []
129
+ replacement_count = 0
130
+
131
+ files.each do |path|
132
+ content = File.read(path)
133
+ fixed, count = fixed_content(path, content)
134
+
135
+ next if count.zero?
136
+
137
+ File.write(path, fixed)
138
+ changed_files << relative_path(path)
139
+ replacement_count += count
140
+ end
141
+
142
+ FixResult.new(changed_files: changed_files, replacement_count: replacement_count)
143
+ end
144
+
145
+ private
146
+
147
+ ##
148
+ # Resolved Semverve configuration.
149
+ #
150
+ # @return [Semverve::ResolvedConfiguration]
151
+ attr_reader :configuration
152
+
153
+ ##
154
+ # Current gem version.
155
+ #
156
+ # @return [Semverve::SemanticVersion]
157
+ attr_reader :current_version
158
+
159
+ ##
160
+ # Absolute configured project root.
161
+ #
162
+ # @return [String]
163
+ def root
164
+ configuration.root
165
+ end
166
+
167
+ ##
168
+ # Absolute files to scan.
169
+ #
170
+ # @return [Array<String>]
171
+ def files
172
+ @files ||= FileListResolver.new(root: root, file_list: configuration.version_doc_reference_files).files
173
+ .reject { |path| ignored_path?(path) }
174
+ end
175
+
176
+ ##
177
+ # Whether the file should always be skipped.
178
+ #
179
+ # @param [String] path
180
+ #
181
+ # @return [Boolean]
182
+ def ignored_path?(path)
183
+ File.expand_path(path) == File.expand_path(configuration.absolute_version_file)
184
+ end
185
+
186
+ ##
187
+ # Finds references in a single file.
188
+ #
189
+ # @param [String] path
190
+ #
191
+ # @return [Array<Semverve::VersionReferences::Finding>]
192
+ def findings_for_file(path)
193
+ content = File.read(path)
194
+
195
+ scannable_segments(path, content).flat_map do |segment|
196
+ findings_for_segment(path, segment, content)
197
+ end
198
+ end
199
+
200
+ ##
201
+ # Returns fixed file content and replacement count.
202
+ #
203
+ # @param [String] path
204
+ # @param [String] content
205
+ #
206
+ # @return [Array(String, Integer)]
207
+ def fixed_content(path, content)
208
+ replacements = scannable_segments(path, content).flat_map do |segment|
209
+ replacements_for_segment(segment, content)
210
+ end
211
+
212
+ replacements.sort_by { |replacement| -replacement[:index] }.each do |replacement|
213
+ content[replacement[:index], replacement[:length]] = current_version.to_s
214
+ end
215
+
216
+ [content, replacements.count]
217
+ end
218
+
219
+ ##
220
+ # Scannable text segments for a file.
221
+ #
222
+ # @param [String] path
223
+ # @param [String] content
224
+ #
225
+ # @return [Array<Hash>]
226
+ def scannable_segments(path, content)
227
+ if ruby_file?(path)
228
+ ruby_comment_segments(content)
229
+ elsif text_file?(path)
230
+ text_segments(content)
231
+ else
232
+ []
233
+ end
234
+ end
235
+
236
+ ##
237
+ # Whether a file should be scanned as Ruby comments.
238
+ #
239
+ # @param [String] path
240
+ #
241
+ # @return [Boolean]
242
+ def ruby_file?(path)
243
+ File.extname(path) == ".rb"
244
+ end
245
+
246
+ ##
247
+ # Whether a file should be scanned as text.
248
+ #
249
+ # @param [String] path
250
+ #
251
+ # @return [Boolean]
252
+ def text_file?(path)
253
+ TEXT_EXTENSIONS.include?(File.extname(path)) || File.basename(path).start_with?("README")
254
+ end
255
+
256
+ ##
257
+ # Full-line text segments.
258
+ #
259
+ # @param [String] content
260
+ #
261
+ # @return [Array<Hash>]
262
+ def text_segments(content)
263
+ line_start_indexes(content).each_with_index.map do |index, line_index|
264
+ line = content.lines[line_index]
265
+ {line: line_index + 1, column: 1, index: index, text: line}
266
+ end
267
+ end
268
+
269
+ ##
270
+ # Ruby comment token segments.
271
+ #
272
+ # @param [String] content
273
+ #
274
+ # @return [Array<Hash>]
275
+ def ruby_comment_segments(content)
276
+ line_starts = line_start_indexes(content)
277
+
278
+ Ripper.lex(content).select { |(_position, type, _text)| type == :on_comment }.flat_map do |(position, _type, text)|
279
+ start_line, start_column = position
280
+ token_index = line_starts.fetch(start_line - 1) + start_column
281
+
282
+ split_token(text, start_line, start_column + 1, token_index)
283
+ end
284
+ end
285
+
286
+ ##
287
+ # Splits a possibly multiline token into line-level segments.
288
+ #
289
+ # @param [String] text
290
+ # @param [Integer] start_line
291
+ # @param [Integer] start_column
292
+ # @param [Integer] start_index
293
+ #
294
+ # @return [Array<Hash>]
295
+ def split_token(text, start_line, start_column, start_index)
296
+ index = start_index
297
+
298
+ text.lines.map.with_index do |line, offset|
299
+ column = offset.zero? ? start_column : 1
300
+ segment = {line: start_line + offset, column: column, index: index, text: line}
301
+ index += line.length
302
+ segment
303
+ end
304
+ end
305
+
306
+ ##
307
+ # Finds version references in a segment.
308
+ #
309
+ # @param [String] path
310
+ # @param [Hash] segment
311
+ # @param [String] content
312
+ #
313
+ # @return [Array<Semverve::VersionReferences::Finding>]
314
+ def findings_for_segment(path, segment, content)
315
+ references_for_segment(segment, content).map do |reference|
316
+ Finding.new(
317
+ path: relative_path(path),
318
+ line: segment[:line],
319
+ column: reference[:column],
320
+ version: reference[:version]
321
+ )
322
+ end
323
+ end
324
+
325
+ ##
326
+ # Replacements to make in a segment.
327
+ #
328
+ # @param [Hash] segment
329
+ # @param [String] content
330
+ #
331
+ # @return [Array<Hash>]
332
+ def replacements_for_segment(segment, content)
333
+ references_for_segment(segment, content).map do |reference|
334
+ {
335
+ index: reference[:index],
336
+ length: reference[:length]
337
+ }
338
+ end
339
+ end
340
+
341
+ ##
342
+ # Version references in a segment.
343
+ #
344
+ # @param [Hash] segment
345
+ # @param [String] content
346
+ #
347
+ # @return [Array<Hash>]
348
+ def references_for_segment(segment, content)
349
+ return [] if ignored_line?(segment[:line], content)
350
+
351
+ segment[:text].to_enum(:scan, VERSION_PATTERN).filter_map do
352
+ match = Regexp.last_match
353
+ version = SemanticVersion.parse(match[0])
354
+
355
+ next unless report?(version)
356
+
357
+ {
358
+ column: segment[:column] + match.begin(0),
359
+ index: segment[:index] + match.begin(0),
360
+ length: match[0].length,
361
+ version: version
362
+ }
363
+ end
364
+ end
365
+
366
+ ##
367
+ # Whether a referenced version should be reported.
368
+ #
369
+ # @param [Semverve::SemanticVersion] version
370
+ #
371
+ # @return [Boolean]
372
+ def report?(version)
373
+ case configuration.version_reference_mode
374
+ when :older
375
+ version < current_version
376
+ when :non_current
377
+ version != current_version
378
+ else
379
+ raise Error, "Unknown version reference mode #{configuration.version_reference_mode.inspect}. Use :older or :non_current."
380
+ end
381
+ end
382
+
383
+ ##
384
+ # Whether findings on a line should be ignored.
385
+ #
386
+ # @param [Integer] line
387
+ # @param [String] content
388
+ #
389
+ # @return [Boolean]
390
+ def ignored_line?(line, content)
391
+ lines = content.lines
392
+ return true if lines.fetch(line - 1).include?(IGNORE_MARKER)
393
+
394
+ # Ruby 3.2 and 3.3 do not support Array#rfind.
395
+ # rubocop:disable Style/ReverseFind
396
+ previous_nonblank_line = lines[0...(line - 1)].reverse_each.find { |candidate| !candidate.strip.empty? }
397
+ # rubocop:enable Style/ReverseFind
398
+ previous_nonblank_line&.include?(IGNORE_MARKER)
399
+ end
400
+
401
+ ##
402
+ # Starting index for each line in content.
403
+ #
404
+ # @param [String] content
405
+ #
406
+ # @return [Array<Integer>]
407
+ def line_start_indexes(content)
408
+ index = 0
409
+
410
+ content.lines.map do |line|
411
+ start = index
412
+ index += line.length
413
+ start
414
+ end
415
+ end
416
+
417
+ ##
418
+ # Path relative to the project root.
419
+ #
420
+ # @param [String] path
421
+ #
422
+ # @return [String]
423
+ def relative_path(path)
424
+ path.delete_prefix("#{root}/")
425
+ end
426
+ end
427
+ end
data/lib/semverve.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "semverve/configuration"
4
+ require_relative "semverve/version"
5
+
6
+ ##
7
+ # Namespace for Semverve configuration and version-file Rake tasks.
8
+ module Semverve
9
+ class << self
10
+ ##
11
+ # Yields the global configuration object for customization.
12
+ #
13
+ # @yieldparam [Semverve::Configuration] configuration
14
+ #
15
+ # @return [Semverve::Configuration]
16
+ def configure
17
+ yield configuration
18
+ end
19
+
20
+ ##
21
+ # Returns the global mutable configuration object.
22
+ #
23
+ # @return [Semverve::Configuration]
24
+ def configuration
25
+ @configuration ||= Configuration.new
26
+ end
27
+ end
28
+ end
29
+
30
+ require_relative "semverve/railtie" if defined?(::Rails::Railtie)
data/semverve.gemspec ADDED
@@ -0,0 +1,36 @@
1
+ require_relative "lib/semverve/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "semverve"
5
+ spec.version = Semverve::VERSION
6
+ spec.authors = ["Evan Gray"]
7
+ spec.email = "evanthegrayt@vivaldi.net"
8
+ spec.license = "MIT"
9
+
10
+ spec.summary = %(Rake tasks for managing Ruby gem version files)
11
+ spec.description = %(Semverve adds Rake tasks that read, generate, and increment Ruby gem version files.)
12
+ spec.homepage = "https://github.com/evanthegrayt/semverve"
13
+ spec.required_ruby_version = ">= 3.2"
14
+
15
+ unless spec.respond_to?(:metadata)
16
+ raise "RubyGems 2.0 or newer is required to protect against " \
17
+ "public gem pushes."
18
+ end
19
+
20
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
21
+ spec.metadata["homepage_uri"] = spec.homepage
22
+ spec.metadata["source_code_uri"] = "https://github.com/evanthegrayt/semverve"
23
+ spec.metadata["documentation_uri"] = "https://evanthegrayt.github.io/semverve/"
24
+
25
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
26
+ f.match(%r{^(test|spec|features)/})
27
+ end
28
+ spec.bindir = "bin"
29
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
30
+ spec.require_paths = ["lib"]
31
+ spec.add_development_dependency "rake", "~> 13.0", ">= 13.0.1"
32
+ spec.add_development_dependency "rdoc"
33
+ spec.add_development_dependency "simplecov"
34
+ spec.add_development_dependency "standard", "~> 1.54.0"
35
+ spec.add_development_dependency "test-unit", "~> 3.3", ">= 3.3.5"
36
+ end
metadata ADDED
@@ -0,0 +1,156 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: semverve
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Evan Gray
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rake
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '13.0'
19
+ - - ">="
20
+ - !ruby/object:Gem::Version
21
+ version: 13.0.1
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - "~>"
27
+ - !ruby/object:Gem::Version
28
+ version: '13.0'
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: 13.0.1
32
+ - !ruby/object:Gem::Dependency
33
+ name: rdoc
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '0'
39
+ type: :development
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: simplecov
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ - !ruby/object:Gem::Dependency
61
+ name: standard
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: 1.54.0
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: 1.54.0
74
+ - !ruby/object:Gem::Dependency
75
+ name: test-unit
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '3.3'
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: 3.3.5
84
+ type: :development
85
+ prerelease: false
86
+ version_requirements: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - "~>"
89
+ - !ruby/object:Gem::Version
90
+ version: '3.3'
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: 3.3.5
94
+ description: Semverve adds Rake tasks that read, generate, and increment Ruby gem
95
+ version files.
96
+ email: evanthegrayt@vivaldi.net
97
+ executables: []
98
+ extensions: []
99
+ extra_rdoc_files: []
100
+ files:
101
+ - ".github/workflows/ruby.yml"
102
+ - ".gitignore"
103
+ - ".ruby-version"
104
+ - CONTRIBUTING.md
105
+ - Gemfile
106
+ - Gemfile.lock
107
+ - LICENSE
108
+ - README.md
109
+ - Rakefile
110
+ - lib/semverve.rb
111
+ - lib/semverve/configuration.rb
112
+ - lib/semverve/docs_publisher.rb
113
+ - lib/semverve/docs_publisher/task.rb
114
+ - lib/semverve/error.rb
115
+ - lib/semverve/file_list_resolver.rb
116
+ - lib/semverve/formats.rb
117
+ - lib/semverve/formats/module_constants.rb
118
+ - lib/semverve/formats/simple_string.rb
119
+ - lib/semverve/generator.rb
120
+ - lib/semverve/presets.rb
121
+ - lib/semverve/project_metadata.rb
122
+ - lib/semverve/railtie.rb
123
+ - lib/semverve/semantic_version.rb
124
+ - lib/semverve/task.rb
125
+ - lib/semverve/version.rb
126
+ - lib/semverve/version_code_references.rb
127
+ - lib/semverve/version_file.rb
128
+ - lib/semverve/version_metadata.rb
129
+ - lib/semverve/version_references.rb
130
+ - semverve.gemspec
131
+ homepage: https://github.com/evanthegrayt/semverve
132
+ licenses:
133
+ - MIT
134
+ metadata:
135
+ allowed_push_host: https://rubygems.org
136
+ homepage_uri: https://github.com/evanthegrayt/semverve
137
+ source_code_uri: https://github.com/evanthegrayt/semverve
138
+ documentation_uri: https://evanthegrayt.github.io/semverve/
139
+ rdoc_options: []
140
+ require_paths:
141
+ - lib
142
+ required_ruby_version: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - ">="
145
+ - !ruby/object:Gem::Version
146
+ version: '3.2'
147
+ required_rubygems_version: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: '0'
152
+ requirements: []
153
+ rubygems_version: 4.0.10
154
+ specification_version: 4
155
+ summary: Rake tasks for managing Ruby gem version files
156
+ test_files: []