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,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+ require_relative "formats"
5
+
6
+ module Semverve
7
+ ##
8
+ # Reads and updates an existing version file.
9
+ class VersionFile
10
+ ##
11
+ # Result of attempting to update a version file.
12
+ class UpdateResult
13
+ ##
14
+ # Version parsed before the update.
15
+ #
16
+ # @return [Semverve::SemanticVersion]
17
+ attr_reader :previous_version
18
+
19
+ ##
20
+ # Version requested by the update.
21
+ #
22
+ # @return [Semverve::SemanticVersion]
23
+ attr_reader :version
24
+
25
+ ##
26
+ # Initializes an update result.
27
+ #
28
+ # @param [Semverve::SemanticVersion] previous_version
29
+ # @param [Semverve::SemanticVersion] version
30
+ # @param [Boolean] changed
31
+ #
32
+ # @return [Semverve::VersionFile::UpdateResult]
33
+ def initialize(previous_version:, version:, changed:)
34
+ @previous_version = previous_version
35
+ @version = version
36
+ @changed = changed
37
+ end
38
+
39
+ ##
40
+ # Whether the version file was changed.
41
+ #
42
+ # @return [Boolean]
43
+ def changed?
44
+ @changed
45
+ end
46
+ end
47
+
48
+ ##
49
+ # Initializes a version-file reader and writer.
50
+ #
51
+ # @param [Semverve::ResolvedConfiguration] configuration
52
+ #
53
+ # @return [Semverve::VersionFile]
54
+ def initialize(configuration)
55
+ @configuration = configuration
56
+ @format = Formats.fetch(configuration.format)
57
+ end
58
+
59
+ ##
60
+ # Current semantic version parsed from the version file.
61
+ #
62
+ # @return [Semverve::SemanticVersion]
63
+ def current
64
+ format.parse(read, path: path)
65
+ end
66
+
67
+ ##
68
+ # Increments one semantic-version level and writes the result.
69
+ #
70
+ # @param [Symbol, String] level
71
+ #
72
+ # @return [Semverve::VersionFile::UpdateResult]
73
+ def increment(level)
74
+ update { |version| version.increment(level) }
75
+ end
76
+
77
+ ##
78
+ # Sets the version file to an explicit semantic version.
79
+ #
80
+ # @param [Semverve::SemanticVersion] version
81
+ #
82
+ # @return [Semverve::VersionFile::UpdateResult]
83
+ def set(version)
84
+ update { version }
85
+ end
86
+
87
+ private
88
+
89
+ ##
90
+ # Resolved configuration used to locate the version file.
91
+ #
92
+ # @return [Semverve::ResolvedConfiguration]
93
+ attr_reader :configuration
94
+
95
+ ##
96
+ # Format handler used to parse and replace version content.
97
+ #
98
+ # @return [Semverve::Formats::ModuleConstants, Semverve::Formats::SimpleString]
99
+ attr_reader :format
100
+
101
+ ##
102
+ # Absolute path to the configured version file.
103
+ #
104
+ # @return [String]
105
+ def path
106
+ configuration.absolute_version_file
107
+ end
108
+
109
+ ##
110
+ # Reads the configured version file.
111
+ #
112
+ # @return [String]
113
+ def read
114
+ unless File.file?(path)
115
+ raise Error, "Could not find version file #{path}. Run rake semverve:generate or set config.version_file."
116
+ end
117
+
118
+ File.read(path)
119
+ end
120
+
121
+ ##
122
+ # Updates the version file with the version returned by the block.
123
+ #
124
+ # @yieldparam [Semverve::SemanticVersion] version
125
+ #
126
+ # @return [Semverve::VersionFile::UpdateResult]
127
+ def update
128
+ content = read
129
+ previous_version = format.parse(content, path: path)
130
+ next_version = yield previous_version
131
+ changed = previous_version != next_version
132
+
133
+ File.write(path, format.replace(content, next_version, path: path)) if changed
134
+
135
+ UpdateResult.new(
136
+ previous_version: previous_version,
137
+ version: next_version,
138
+ changed: changed
139
+ )
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,358 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+ require "rubygems"
5
+
6
+ require_relative "semantic_version"
7
+
8
+ module Semverve
9
+ ##
10
+ # Checks generated gem metadata against the configured version file.
11
+ class VersionMetadata
12
+ ##
13
+ # Literal gemspec version assignments that can be safely rewritten.
14
+ #
15
+ # @return [Regexp]
16
+ GEMSPEC_LITERAL_PATTERN = /^(\s*\w+\.version\s*=\s*)(["'])(\d+\.\d+\.\d+)(\2)/
17
+
18
+ ##
19
+ # A metadata version mismatch.
20
+ class Finding
21
+ ##
22
+ # Path relative to the configured project root.
23
+ #
24
+ # @return [String]
25
+ attr_reader :path
26
+
27
+ ##
28
+ # One-based line number.
29
+ #
30
+ # @return [Integer]
31
+ attr_reader :line
32
+
33
+ ##
34
+ # One-based column number.
35
+ #
36
+ # @return [Integer]
37
+ attr_reader :column
38
+
39
+ ##
40
+ # Metadata semantic version.
41
+ #
42
+ # @return [Semverve::SemanticVersion]
43
+ attr_reader :version
44
+
45
+ ##
46
+ # Output label for the finding.
47
+ #
48
+ # @return [String]
49
+ attr_reader :label
50
+
51
+ ##
52
+ # Initializes a finding.
53
+ #
54
+ # @param [String] path
55
+ # @param [Integer] line
56
+ # @param [Integer] column
57
+ # @param [Semverve::SemanticVersion] version
58
+ # @param [String] label
59
+ #
60
+ # @return [Semverve::VersionMetadata::Finding]
61
+ def initialize(path:, line:, column:, version:, label:)
62
+ @path = path
63
+ @line = line
64
+ @column = column
65
+ @version = version
66
+ @label = label
67
+ end
68
+ end
69
+
70
+ ##
71
+ # Result of fixing metadata version mismatches.
72
+ class FixResult
73
+ ##
74
+ # Files changed by the fix.
75
+ #
76
+ # @return [Array<String>]
77
+ attr_reader :changed_files
78
+
79
+ ##
80
+ # Number of literal replacements made.
81
+ #
82
+ # @return [Integer]
83
+ attr_reader :replacement_count
84
+
85
+ ##
86
+ # Whether bundle lock was run.
87
+ #
88
+ # @return [Boolean]
89
+ attr_reader :bundle_lock_ran
90
+
91
+ ##
92
+ # Initializes a fix result.
93
+ #
94
+ # @param [Array<String>] changed_files
95
+ # @param [Integer] replacement_count
96
+ # @param [Boolean] bundle_lock_ran
97
+ #
98
+ # @return [Semverve::VersionMetadata::FixResult]
99
+ def initialize(changed_files:, replacement_count:, bundle_lock_ran:)
100
+ @changed_files = changed_files
101
+ @replacement_count = replacement_count
102
+ @bundle_lock_ran = bundle_lock_ran
103
+ end
104
+ end
105
+
106
+ ##
107
+ # Initializes metadata version checking.
108
+ #
109
+ # @param [Semverve::ResolvedConfiguration] configuration
110
+ # @param [Semverve::SemanticVersion] current_version
111
+ #
112
+ # @return [Semverve::VersionMetadata]
113
+ def initialize(configuration, current_version)
114
+ @configuration = configuration
115
+ @current_version = current_version
116
+ end
117
+
118
+ ##
119
+ # Metadata mismatches.
120
+ #
121
+ # @return [Array<Semverve::VersionMetadata::Finding>]
122
+ def findings
123
+ [gemspec_finding, lockfile_finding].compact
124
+ end
125
+
126
+ ##
127
+ # Fixes safe metadata mismatches.
128
+ #
129
+ # @return [Semverve::VersionMetadata::FixResult]
130
+ def fix
131
+ changed_files = []
132
+ replacement_count = fix_gemspec_literal
133
+
134
+ changed_files << relative_path(gemspec_file) if replacement_count.positive?
135
+
136
+ bundle_lock_ran = lockfile_finding ? run_bundle_lock : false
137
+
138
+ FixResult.new(
139
+ changed_files: changed_files,
140
+ replacement_count: replacement_count,
141
+ bundle_lock_ran: bundle_lock_ran
142
+ )
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
+ # Finding for a gemspec mismatch.
169
+ #
170
+ # @return [Semverve::VersionMetadata::Finding, nil]
171
+ def gemspec_finding
172
+ return unless gemspec_file && gemspec_version
173
+ return if gemspec_version == current_version
174
+
175
+ line, column = gemspec_position
176
+
177
+ Finding.new(
178
+ path: relative_path(gemspec_file),
179
+ line: line,
180
+ column: column,
181
+ version: gemspec_version,
182
+ label: "gemspec version"
183
+ )
184
+ end
185
+
186
+ ##
187
+ # Finding for a lockfile mismatch.
188
+ #
189
+ # @return [Semverve::VersionMetadata::Finding, nil]
190
+ def lockfile_finding
191
+ return unless File.file?(lockfile_path)
192
+
193
+ version = lockfile_version
194
+ return unless version
195
+ return if version == current_version
196
+
197
+ line, column = lockfile_position(version)
198
+
199
+ Finding.new(
200
+ path: "Gemfile.lock",
201
+ line: line,
202
+ column: column,
203
+ version: version,
204
+ label: "locked version"
205
+ )
206
+ end
207
+
208
+ ##
209
+ # Absolute gemspec path for the configured gem.
210
+ #
211
+ # @return [String, nil]
212
+ def gemspec_file
213
+ @gemspec_file ||= begin
214
+ files = Dir.glob(File.join(root, "*.gemspec"))
215
+
216
+ if files.one?
217
+ files.first
218
+ else
219
+ files.find do |path|
220
+ spec = load_gemspec(path)
221
+ spec&.name == configuration.gem_name
222
+ end
223
+ end
224
+ end
225
+ end
226
+
227
+ ##
228
+ # Version loaded from the matching gemspec.
229
+ #
230
+ # @return [Semverve::SemanticVersion, nil]
231
+ def gemspec_version
232
+ @gemspec_version ||= begin
233
+ spec = load_gemspec(gemspec_file)
234
+
235
+ SemanticVersion.parse(spec.version.to_s) if spec&.version
236
+ end
237
+ end
238
+
239
+ ##
240
+ # Loads a gemspec.
241
+ #
242
+ # @param [String, nil] path
243
+ #
244
+ # @return [Gem::Specification, nil]
245
+ def load_gemspec(path)
246
+ return unless path
247
+
248
+ Gem::Specification.load(path)
249
+ end
250
+
251
+ ##
252
+ # Line and column for gemspec output.
253
+ #
254
+ # @return [Array(Integer, Integer)]
255
+ def gemspec_position
256
+ lines = File.readlines(gemspec_file)
257
+
258
+ lines.each_with_index do |line, index|
259
+ literal_match = line.match(GEMSPEC_LITERAL_PATTERN)
260
+ return [index + 1, literal_match.begin(3) + 1] if literal_match
261
+
262
+ assignment_index = line.index(/^\s*\w+\.version\s*=/)
263
+ return [index + 1, assignment_index + 1] if assignment_index
264
+ end
265
+
266
+ [1, 1]
267
+ end
268
+
269
+ ##
270
+ # Version parsed from Gemfile.lock.
271
+ #
272
+ # @return [Semverve::SemanticVersion, nil]
273
+ def lockfile_version
274
+ spec = lockfile_parser.specs.find { |candidate| candidate.name == configuration.gem_name }
275
+ return unless spec
276
+
277
+ SemanticVersion.parse(spec.version.to_s)
278
+ end
279
+
280
+ ##
281
+ # Parsed Gemfile.lock.
282
+ #
283
+ # @return [Bundler::LockfileParser]
284
+ def lockfile_parser
285
+ Bundler::LockfileParser.new(File.read(lockfile_path))
286
+ end
287
+
288
+ ##
289
+ # Line and column for lockfile output.
290
+ #
291
+ # @param [Semverve::SemanticVersion] version
292
+ #
293
+ # @return [Array(Integer, Integer)]
294
+ def lockfile_position(version)
295
+ pattern = /^\s*#{Regexp.escape(configuration.gem_name)}\s+\(#{Regexp.escape(version.to_s)}\)/
296
+
297
+ File.readlines(lockfile_path).each_with_index do |line, index|
298
+ match = line.match(pattern)
299
+ return [index + 1, line.index(version.to_s) + 1] if match
300
+ end
301
+
302
+ [1, 1]
303
+ end
304
+
305
+ ##
306
+ # Fixes a literal gemspec version assignment.
307
+ #
308
+ # @return [Integer]
309
+ def fix_gemspec_literal
310
+ return 0 unless gemspec_file
311
+
312
+ content = File.read(gemspec_file)
313
+ replacement_count = 0
314
+
315
+ fixed = content.lines.map do |line|
316
+ match = line.match(GEMSPEC_LITERAL_PATTERN)
317
+ next line unless match
318
+
319
+ version = SemanticVersion.parse(match[3])
320
+ next line if version == current_version
321
+
322
+ replacement_count += 1
323
+ line.sub(GEMSPEC_LITERAL_PATTERN) { "#{$1}#{$2}#{current_version}#{$4}" }
324
+ end.join
325
+
326
+ File.write(gemspec_file, fixed) if replacement_count.positive?
327
+
328
+ replacement_count
329
+ end
330
+
331
+ ##
332
+ # Runs bundle lock through the configured command runner.
333
+ #
334
+ # @return [Boolean]
335
+ def run_bundle_lock
336
+ configuration.command_runner.call("bundle lock")
337
+ true
338
+ end
339
+
340
+ ##
341
+ # Absolute Gemfile.lock path.
342
+ #
343
+ # @return [String]
344
+ def lockfile_path
345
+ File.join(root, "Gemfile.lock")
346
+ end
347
+
348
+ ##
349
+ # Path relative to the project root.
350
+ #
351
+ # @param [String] path
352
+ #
353
+ # @return [String]
354
+ def relative_path(path)
355
+ path.delete_prefix("#{root}/")
356
+ end
357
+ end
358
+ end