gemstar 1.2 → 1.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c6157dce9498afdbda3d014af6e1e4e20e7477d73bd3b12129c0d754df28f4f7
4
- data.tar.gz: c7fcc69688c21fea79ead9b9f762d026a5b57e5c597206171fe6faaeb5555db1
3
+ metadata.gz: 6eab7d36aeffe59f5863dffb07696c7ed8aab64dfb8828ae962122d607ad0b28
4
+ data.tar.gz: 9120441dae108fb6ff9d9a009cb9e6988349b884f011c40037460a86c45ef42a
5
5
  SHA512:
6
- metadata.gz: 89b29cc2ff749b326e1e8dfd2cc98279b45d05a961b1d1aab8ea0cfcc9e4c2acab238fbaf40c4b947b0c9201a6254a80beb5102aa8c28c348d1afde67ced305a
7
- data.tar.gz: 55182c82a720bd6a5baf9ab26e7369df6c834c24ae959b05301a40da16ac0e447a6ada98ac2461064ba137f9e944191026c48084d1ec2f95e923c0cda6b19265
6
+ metadata.gz: 5857f33b3a6b36759b2978b768749d7240f8ce54c73e57c48bce10ca407db846a955b80ba7c93d5db2a03bccbc6cb964ee55759ed4d75deb6982e23da681f5c8
7
+ data.tar.gz: 61dc580cfbb69aca3535428107a299833343ad132bb5e437cc01380ae4d58d90d525498818aed462793b036348e9f3666d83d8c6a7d2b821b62313f52c289381
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Change Log
2
2
 
3
+ ## 1.3
4
+
5
+ - **Python uv projects** are now supported with our new `uv.lock` file parser and pypi package support!
6
+ - **Rust Cargo projects** are now supported with a new `Cargo.lock` parser and crates.io package support!
7
+ - Changelogs in rst format are now supported.
8
+ - Changelogs: Fix parsing of documentation percentages, linked version headings, and version-first headings with dotted dates.
9
+ - Changelogs: Do not treat version-looking prose inside release notes as a new release heading.
10
+ - Changelogs: Prefer repository-branch changelogs when gem metadata points to a release-pinned file that may omit its own release.
11
+ - Changelogs: Discover extensionless changelog files and direct GitHub changelog links, including Roda and Selenium release histories.
12
+ - Changelogs: Correct known upstream release-heading typos through package metadata, including Thruster 0.1.23.
13
+ - Server: Sort changelog cards with semantic version comparison so Selenium's `4.x` releases appear ahead of `3.142.x` and `3.141.x`.
14
+ - Server: Render linked changelog version headings without Markdown brackets.
15
+ - Server: Keep the details focus ring visible around its sticky header.
16
+ - Server: Keep keyboard-selected sidebar packages visible below the sticky filter header.
17
+ - Documentation: List Python package support in the README and gem metadata.
18
+ - Server: Details heading is now sticky.
19
+ - Server: Changed display of available versions to be more clear and concise.
20
+ - CLI: Improve message shown when no supported project files are found.
21
+ - CLI: Add `--version` to print the Gemstar version.
22
+
3
23
  ## 1.2
4
24
 
5
25
  - Server: Cache warmer now pre-fetches package detail pages so browsing warmed gems is faster.
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![JRuby Build](https://github.com/FDj/gemstar/workflows/JRuby%20Build/badge.svg)](https://github.com/FDj/gemstar/actions)
4
4
 
5
5
  # Gemstar
6
- Helps you keep track of your gems and JavaScript packages.
6
+ Helps you keep track of your Ruby gems, JavaScript packages, Python packages, and Rust crates.
7
7
 
8
8
  ## Installation
9
9
 
@@ -72,7 +72,7 @@ To examine a specific Gemfile.lock, pass it like this:
72
72
  gemstar diff --lockfile=~/MyProject/Gemfile.lock
73
73
  ```
74
74
 
75
- To diff a project from anywhere, pass the project directory or a supported project file. In project mode, gemstar includes Ruby gems plus JS packages from `importmap.rb` and `package-lock.json` when present:
75
+ To diff a project from anywhere, pass the project directory or a supported project file. In project mode, gemstar includes Ruby gems, JS packages from `importmap.rb` and `package-lock.json`, Python packages from `uv.lock`, and Rust crates from `Cargo.lock` when present:
76
76
 
77
77
  ```shell
78
78
  gemstar diff --project ~/Code/my-app
@@ -83,6 +83,8 @@ To filter a project diff down to one ecosystem:
83
83
  ```shell
84
84
  gemstar diff --project ~/Code/my-app --ecosystem js
85
85
  gemstar diff --project ~/Code/my-app --ecosystem gems
86
+ gemstar diff --project ~/Code/my-app --ecosystem python
87
+ gemstar diff --project ~/Code/my-app --ecosystem cargo
86
88
  ```
87
89
 
88
90
  To write markdown instead of html:
@@ -1,5 +1,7 @@
1
1
  require "set"
2
2
  require "thread"
3
+ require_relative "crates_io_metadata"
4
+ require_relative "pypi_metadata"
3
5
 
4
6
  module Gemstar
5
7
  class CacheWarmer
@@ -18,6 +20,7 @@ module Gemstar
18
20
  @completed = Set.new
19
21
  @workers = []
20
22
  @started = false
23
+ @stopping = false
21
24
  @total = 0
22
25
  @completed_count = 0
23
26
  end
@@ -28,6 +31,8 @@ module Gemstar
28
31
  states = normalize_package_states(package_states)
29
32
 
30
33
  @mutex.synchronize do
34
+ return self if @stopping
35
+
31
36
  states.each do |package_state|
32
37
  key = package_key(package_state)
33
38
  next if @completed.include?(key) || @queued.include?(key) || @in_progress.include?(key)
@@ -74,6 +79,28 @@ module Gemstar
74
79
  @condition.broadcast
75
80
  end
76
81
 
82
+ def shutdown(timeout: 0.5)
83
+ workers = nil
84
+
85
+ @mutex.synchronize do
86
+ @stopping = true
87
+ @queue.clear
88
+ @queued.clear
89
+ @condition.broadcast
90
+ workers = @workers.dup
91
+ end
92
+
93
+ workers.each do |worker|
94
+ worker.join(timeout)
95
+ worker.kill if worker.alive?
96
+ end
97
+
98
+ @mutex.synchronize do
99
+ @workers.clear
100
+ @started = false
101
+ end
102
+ end
103
+
77
104
  def pending?(package_name)
78
105
  @mutex.synchronize do
79
106
  @queue.any? { |item| item[:name] == package_name || item.dig(:source, :package_name) == package_name } ||
@@ -97,9 +124,10 @@ module Gemstar
97
124
 
98
125
  loop do
99
126
  package_state = @mutex.synchronize do
100
- while @queue.empty?
127
+ while @queue.empty? && !@stopping
101
128
  @condition.wait(@mutex)
102
129
  end
130
+ return if @stopping && @queue.empty?
103
131
 
104
132
  next_package = @queue.shift
105
133
  key = package_key(next_package)
@@ -180,6 +208,13 @@ module Gemstar
180
208
  end
181
209
 
182
210
  def metadata_adapter_for(package_state)
211
+ if package_state[:package_scope] == "cargo"
212
+ package_name = package_state[:metadata_package_name] || package_state.dig(:source, :package_name) || package_state[:name]
213
+ return Gemstar::CratesIOMetadata.new(package_name)
214
+ end
215
+
216
+ return Gemstar::PyPIMetadata.new(package_state[:name]) if package_state[:package_scope] == "python"
217
+
183
218
  if package_state[:package_scope] == "js"
184
219
  provider_gem = package_state.dig(:source, :provider_gem)
185
220
  return Gemstar::RubyGemsMetadata.new(provider_gem) unless provider_gem.to_s.empty?
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Gemstar
6
+ class CargoLockFile
7
+ attr_reader :specs
8
+ attr_reader :spec_sources
9
+
10
+ def initialize(path: nil, content: nil)
11
+ parsed = parse_content(content || File.read(path))
12
+ @specs = parsed[:specs]
13
+ @spec_sources = parsed[:spec_sources]
14
+ end
15
+
16
+ def source_for(name)
17
+ spec_sources[name]
18
+ end
19
+
20
+ private
21
+
22
+ def parse_content(content)
23
+ packages = package_blocks(content).filter_map do |block|
24
+ name = scalar_value(block, "name")
25
+ version = scalar_value(block, "version")
26
+ source = scalar_value(block, "source")
27
+ next if name.to_s.empty? || version.to_s.empty?
28
+ next if source.to_s.empty?
29
+
30
+ {
31
+ name: name,
32
+ version: version,
33
+ source: source_for_package(source, name, version)
34
+ }
35
+ end
36
+
37
+ specs = {}
38
+ spec_sources = {}
39
+ packages.group_by { |package| package[:name] }.each_value do |named_packages|
40
+ sorted_packages(named_packages).each_with_index do |package, index|
41
+ key = package_key(package, index, named_packages.length)
42
+ specs[key] = package[:version]
43
+ spec_sources[key] = package[:source]
44
+ end
45
+ end
46
+
47
+ {specs: specs, spec_sources: spec_sources}
48
+ end
49
+
50
+ def package_blocks(content)
51
+ content.to_s.split(/^\[\[package\]\]\s*$/).drop(1)
52
+ end
53
+
54
+ def scalar_value(block, key)
55
+ block[/^#{Regexp.escape(key)}\s*=\s*"((?:\\.|[^"])*)"/, 1]&.gsub('\\"', '"')
56
+ end
57
+
58
+ def source_for_package(raw_source, name, version)
59
+ if raw_source.start_with?("git+")
60
+ git_source(raw_source, name, version)
61
+ else
62
+ registry_source(raw_source, name, version)
63
+ end
64
+ end
65
+
66
+ def git_source(raw_source, name, version)
67
+ remote_with_query, revision = raw_source.delete_prefix("git+").split("#", 2)
68
+ remote, query = remote_with_query.split("?", 2)
69
+ params = URI.decode_www_form(query.to_s).to_h
70
+
71
+ {
72
+ type: :git,
73
+ remote: remote,
74
+ revision: revision,
75
+ branch: params["branch"],
76
+ tag: params["tag"],
77
+ package_name: name,
78
+ package_version: version
79
+ }.compact
80
+ rescue ArgumentError
81
+ {
82
+ type: :git,
83
+ remote: raw_source.delete_prefix("git+").split(/[?#]/, 2).first,
84
+ revision: raw_source.split("#", 2)[1],
85
+ package_name: name,
86
+ package_version: version
87
+ }.compact
88
+ end
89
+
90
+ def registry_source(raw_source, name, version)
91
+ registry = raw_source.sub(/\A(?:registry|sparse)\+/, "")
92
+ crates_io = crates_io_registry?(registry)
93
+
94
+ {
95
+ type: :cargo,
96
+ remote: registry,
97
+ package_name: name,
98
+ package_version: version,
99
+ registry_url: crates_io ? "https://crates.io/crates/#{name}" : registry,
100
+ crates_io: crates_io
101
+ }
102
+ end
103
+
104
+ def crates_io_registry?(registry)
105
+ registry.include?("crates.io-index") || registry.include?("index.crates.io")
106
+ end
107
+
108
+ def sorted_packages(packages)
109
+ packages.sort do |left, right|
110
+ comparison = compare_versions(right[:version], left[:version])
111
+ comparison.zero? ? left[:source].to_s <=> right[:source].to_s : comparison
112
+ end
113
+ end
114
+
115
+ def compare_versions(left, right)
116
+ Gem::Version.new(left) <=> Gem::Version.new(right)
117
+ rescue ArgumentError
118
+ left.to_s <=> right.to_s
119
+ end
120
+
121
+ def package_key(package, index, package_count)
122
+ return package[:name] if package_count == 1
123
+
124
+ key = "#{package[:name]}@#{package[:version]}"
125
+ if index.zero? || !@used_package_keys&.include?(key)
126
+ remember_package_key(key)
127
+ else
128
+ remember_package_key("#{key}:#{index + 1}")
129
+ end
130
+ end
131
+
132
+ def remember_package_key(key)
133
+ @used_package_keys ||= []
134
+ @used_package_keys << key
135
+ key
136
+ end
137
+ end
138
+ end
@@ -12,12 +12,21 @@ module Gemstar
12
12
  class ChangeLog
13
13
  @@candidates_found = Hash.new(0)
14
14
  GITHUB_CLI_TIMEOUT = 8
15
+ MAX_CHANGELOG_REDIRECTS = 3
16
+ CHANGELOG_REDIRECT_PATTERN = /\b(?:change\s*log|changelog|release\s+notes?)\b.{0,160}\b(?:moved|relocated|now\s+lives|lives\s+here)\b/im
15
17
  DEFAULT_CHANGELOG_PATHS = %w[
16
- CHANGELOG.md releases.md CHANGES.md
18
+ CHANGELOG.md CHANGELOG releases.md CHANGES.md
17
19
  Changelog.md changelog.md ChangeLog.md
18
20
  Changes.md changes.md
19
21
  HISTORY.md History.md history.md
20
- History CHANGELOG.rdoc
22
+ History
23
+ RELEASE_NOTES.md Release_Notes.md release_notes.md
24
+ RELEASE-NOTES.md Release-Notes.md release-notes.md
25
+ CHANGELOG.rst Changelog.rst changelog.rst ChangeLog.rst
26
+ CHANGES.rst Changes.rst changes.rst
27
+ HISTORY.rst History.rst history.rst
28
+ CHANGES Changes
29
+ CHANGELOG.rdoc
21
30
  ].freeze
22
31
 
23
32
  def initialize(metadata)
@@ -38,7 +47,7 @@ module Gemstar
38
47
  return @sections if !cache_only && defined?(@sections) && !force_refresh
39
48
 
40
49
  metadata_key = @metadata.respond_to?(:cache_key) ? @metadata.cache_key : @metadata.gem_name
41
- cache_key = "sections-v5-#{metadata_key}"
50
+ cache_key = "sections-v10-#{metadata_key}"
42
51
  serialized = if cache_only
43
52
  Cache.peek(cache_key)
44
53
  else
@@ -182,25 +191,10 @@ module Gemstar
182
191
  value = version.to_s.strip
183
192
  return nil if value.empty?
184
193
 
185
- value.sub(/\Av/i, "")
186
- end
187
-
188
- # Extract a version token from a heading line, preferring explicit version forms
189
- # and avoiding returning a date string when both are present.
190
- def extract_version_from_heading(line)
191
- return nil unless line
192
- heading = line.to_s
193
- version_token = /(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)/
194
- # 1) Prefer version inside parentheses after a date: "### 2025-11-07 (2.16.0)"
195
- # Ensure we ONLY treat it as a version if it actually looks like a version (has a dot),
196
- # so we don't capture dates like (2025-11-21).
197
- return $1 if heading[/\(\s*v?#{version_token}(?![A-Za-z0-9])\s*\)/]
198
- # 2) Version-first with optional leading markers/labels: "## v1.2.6 - 2025-10-21"
199
- # Require a dot in the numeric token to avoid capturing dates like 2025-11-21.
200
- return $1 if heading[/^\s*(?:[-*]\s+)?(?:#+|=+)?\s*(?:Version\s+)?\[*v?#{version_token}(?![A-Za-z0-9])\]*/i]
201
- # 3) Anywhere: first semver-like token with a dot
202
- return $1 if heading[/\bv?#{version_token}(?![A-Za-z0-9])\b/]
203
- nil
194
+ value = value.sub(/\Av/i, "")
195
+ Gem::Version.new(value.gsub(/-[\w\-]+$/, "")).canonical_segments.join(".")
196
+ rescue ArgumentError
197
+ value
204
198
  end
205
199
 
206
200
  def extract_release_date_from_heading(line)
@@ -211,23 +205,24 @@ module Gemstar
211
205
  end
212
206
 
213
207
  def changelog_uri_candidates(cache_only: false, force_refresh: false)
214
- candidates = []
215
-
216
208
  repo_uri = @metadata.repo_uri(cache_only: cache_only, force_refresh: force_refresh)
217
209
  return [] if repo_uri.nil? || repo_uri.empty?
218
210
 
219
211
  meta = @metadata.meta(cache_only: cache_only, force_refresh: force_refresh)
220
- candidates += changelog_uri_markdown_candidates(meta["changelog_uri"]) if meta
212
+ explicit_candidates = meta ? changelog_uri_markdown_candidates(meta["changelog_uri"]) : []
221
213
 
222
214
  changelog_source = metadata_changelog_source(repo_uri, cache_only: cache_only, force_refresh: force_refresh)
223
215
  return [] unless changelog_source
224
216
 
225
- candidates += changelog_source[:paths].product(changelog_source[:branches]).map do |file, branch|
217
+ repository_candidates = changelog_source[:paths].product(changelog_source[:branches]).map do |file, branch|
226
218
  [changelog_source[:base], branch, file].reject { |segment| segment.to_s.empty? }.join("/")
227
219
  end
228
220
 
229
- # Add the gem's changelog_uri last as it's usually not the most parsable:
230
- candidates += [Gemstar::GitHub::github_blob_to_raw(meta["changelog_uri"])] if meta
221
+ candidates = if version_pinned_changelog_uri?(meta)
222
+ repository_candidates + explicit_candidates
223
+ else
224
+ explicit_candidates + repository_candidates
225
+ end
231
226
 
232
227
  candidates.flatten!
233
228
  candidates.uniq!
@@ -236,12 +231,24 @@ module Gemstar
236
231
  candidates
237
232
  end
238
233
 
234
+ def version_pinned_changelog_uri?(meta)
235
+ changelog_uri = meta&.fetch("changelog_uri", nil).to_s
236
+ version = meta&.fetch("version", nil).to_s.sub(/\Av/i, "")
237
+ return false if changelog_uri.empty? || version.empty?
238
+
239
+ raw_uri = Gemstar::GitHub::github_blob_to_raw(changelog_uri)
240
+ path_segments = URI(raw_uri).path.split("/")
241
+ path_segments.include?(version) || path_segments.include?("v#{version}")
242
+ rescue URI::InvalidURIError
243
+ false
244
+ end
245
+
239
246
  def changelog_uri_markdown_candidates(changelog_uri)
240
247
  raw_uri = Gemstar::GitHub::github_blob_to_raw(changelog_uri)
241
248
  return [] if raw_uri.to_s.empty?
242
249
 
243
250
  candidates = []
244
- candidates << raw_uri if raw_uri.match?(/\.(?:md|markdown|rdoc|txt)\z/i)
251
+ candidates << raw_uri if raw_uri.match?(/\.(?:md|markdown|rdoc|rst|txt)\z/i)
245
252
 
246
253
  begin
247
254
  uri = URI(raw_uri)
@@ -249,9 +256,14 @@ module Gemstar
249
256
  if path.end_with?("/")
250
257
  uri.path = "#{path.chomp("/")}.md"
251
258
  candidates << uri.to_s
259
+ uri.path = "#{path.chomp("/")}.rst"
260
+ candidates << uri.to_s
252
261
  elsif File.extname(path).empty?
262
+ candidates << raw_uri if uri.host == "raw.githubusercontent.com"
253
263
  uri.path = "#{path}.md"
254
264
  candidates << uri.to_s
265
+ uri.path = "#{path}.rst"
266
+ candidates << uri.to_s
255
267
  end
256
268
  rescue URI::InvalidURIError
257
269
  nil
@@ -277,15 +289,14 @@ module Gemstar
277
289
  content = nil
278
290
 
279
291
  changelog_uri_candidates(cache_only: cache_only, force_refresh: force_refresh).find do |candidate|
280
- content = if cache_only
281
- Cache.peek("changelog-#{candidate}")
282
- else
283
- Cache.fetch("changelog-#{candidate}", force: force_refresh) do
284
- URI.open(candidate, read_timeout: 8)&.read
285
- rescue => e
286
- puts "#{candidate}: #{e}" if Gemstar.debug?
287
- nil
288
- end
292
+ content = fetch_changelog_uri(candidate, cache_only: cache_only, force_refresh: force_refresh)
293
+ if content
294
+ content = follow_changelog_redirects(
295
+ content,
296
+ source_uri: candidate,
297
+ cache_only: cache_only,
298
+ force_refresh: force_refresh
299
+ )
289
300
  end
290
301
 
291
302
  # puts "fetch_changelog_content #{candidate}:\n#{content}" if Gemstar.debug?
@@ -300,13 +311,74 @@ module Gemstar
300
311
  content
301
312
  end
302
313
 
314
+ def fetch_changelog_uri(uri, cache_only:, force_refresh:)
315
+ if cache_only
316
+ Cache.peek("changelog-#{uri}")
317
+ else
318
+ Cache.fetch("changelog-#{uri}", force: force_refresh) do
319
+ URI.open(uri, read_timeout: 8)&.read
320
+ rescue => e
321
+ puts "#{uri}: #{e}" if Gemstar.debug?
322
+ nil
323
+ end
324
+ end
325
+ end
326
+
327
+ def follow_changelog_redirects(content, source_uri:, cache_only:, force_refresh:)
328
+ current_content = content
329
+ current_uri = source_uri
330
+ visited = {source_uri => true}
331
+
332
+ MAX_CHANGELOG_REDIRECTS.times do
333
+ redirect_uri = changelog_redirect_uri(current_content, current_uri)
334
+ break if redirect_uri.nil? || visited[redirect_uri]
335
+
336
+ redirected_content = fetch_changelog_uri(
337
+ redirect_uri,
338
+ cache_only: cache_only,
339
+ force_refresh: force_refresh
340
+ )
341
+ break if redirected_content.nil? || redirected_content.strip.empty?
342
+
343
+ puts "Following changelog redirect: #{current_uri} -> #{redirect_uri}" if Gemstar.debug?
344
+ visited[redirect_uri] = true
345
+ current_uri = redirect_uri
346
+ current_content = redirected_content
347
+ end
348
+
349
+ current_content
350
+ end
351
+
352
+ def changelog_redirect_uri(content, source_uri)
353
+ text = content.to_s
354
+ return nil if text.bytesize > 4096 || text.lines.count > 12
355
+ return nil unless text.match?(CHANGELOG_REDIRECT_PATTERN)
356
+
357
+ links = text.scan(/\[([^\]]+)\]\(\s*<?([^\s)>]+)>?(?:\s+["'][^"']*["'])?\s*\)/)
358
+ redirect_link = links.find { |link_label, _| link_label.match?(/\A(?:here|changelog|change\s*log|release\s+notes?)\z/i) }
359
+ redirect_link ||= links.find { |_, link_href| link_href.match?(%r{(?:change(?:log|s)|history|releases?)}i) }
360
+ href = redirect_link&.last
361
+ return nil if href.nil?
362
+
363
+ resolved = URI.join(source_uri, CGI.unescapeHTML(href)).to_s
364
+ uri = URI(Gemstar::GitHub::github_blob_to_raw(resolved))
365
+ return nil unless %w[http https].include?(uri.scheme)
366
+
367
+ uri.fragment = nil
368
+ uri.to_s
369
+ rescue URI::InvalidURIError
370
+ nil
371
+ end
372
+
303
373
  VERSION_PATTERNS = [
304
- /^\s*(?:[-*]\s+)?(?:#+|=+)\s*\d{4}-\d{2}-\d{2}\s*\(\s*v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])\s*\)/, # prefer this
305
- /^\s*(?:[-*]\s+)?(?:#+|=+)\s*\[*v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])\]*\s*(?:—|–|-)\s*\d{4}-\d{2}-\d{2}\b/,
306
- /^\s*(?:[-*]\s+)?(?:#+|=+)\s*(?:Version\s+)?(?:(?:[^\s\d][^\s]*\s+)+)\[*v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])\]*(?:\s*[-(].*)?/i,
307
- /^\s*(?:[-*]\s+)?(?:#+|=+)\s*(?:Version\s+)?\[*v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])\]*(?:\s*[-(].*)?/i,
308
- /^\s*(?:[-*]\s+)?(?:Version\s+)?v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])(?:\s*[-(].*)?/i
374
+ /^\s*(?:[-*]\s+)?(?:#+|=+)\s*\d{4}-\d{2}-\d{2}\s*\(\s*v?(\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9]|\s*%)\s*\)/, # prefer this
375
+ /^\s*(?:[-*]\s+)?(?:#+|=+)\s*\[*v?(\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9]|\s*%)\]*\s*(?:—|–|-)\s*\d{4}-\d{2}-\d{2}\b/,
376
+ /^\s*(?:[-*]\s+)?(?:#+|=+)\s*(?:Version\s+)?(?:(?:[^\s\d][^\s]*\s+)+)\[*v?(\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9]|\s*%)\]*(?:\s*[-(].*)?/i,
377
+ /^\s*(?:[-*]\s+)?(?:#+|=+)\s*(?:Version\s+)?\[*v?(\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9]|\s*%)\]*(?:\s*[-(].*)?/i,
378
+ /^\s*(?:[-+*]\s+)?Starting with version\s+v?(\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9]|\s*%)/i,
379
+ /^\s*(?:[-+*]\s+)?(?:Version\s+)?v?(\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9]|\s*%)(?:\s*[-(].*)?\s*$/i
309
380
  ]
381
+ RST_ADORNMENT_PATTERN = /^\s*[=\-~`^"']{3,}\s*$/
310
382
 
311
383
  def parse_changelog_sections(cache_only: false, force_refresh: false)
312
384
  # If the fetched content looks like a GitHub Releases HTML page, return {}
@@ -336,11 +408,13 @@ module Gemstar
336
408
  lines.each do |line|
337
409
  # Convert rdoc to markdown:
338
410
  line = line.gsub(/^=+/) { |m| "#" * m.length }
411
+ next if line.match?(/^\s*\.\.\s+_v?[\w.-]+:/)
412
+ next if line.match?(RST_ADORNMENT_PATTERN)
339
413
 
340
414
  m = VERSION_PATTERNS.lazy.map { |re| line.match(re) }.find(&:itself)
341
415
 
342
416
  if m
343
- new_key = extract_version_from_heading(line) || $1
417
+ new_key = corrected_changelog_version(m[1], line)
344
418
 
345
419
  if current_key
346
420
  sections[current_key] ||= []
@@ -375,14 +449,21 @@ module Gemstar
375
449
 
376
450
  c.lines.each_with_object({}) do |line, dates|
377
451
  line = line.gsub(/^=+/) { |m| "#" * m.length }
378
- next unless VERSION_PATTERNS.any? { |re| line.match?(re) }
452
+ match = VERSION_PATTERNS.lazy.map { |pattern| line.match(pattern) }.find(&:itself)
453
+ next unless match
379
454
 
380
- version = extract_version_from_heading(line)
455
+ version = corrected_changelog_version(match[1], line)
381
456
  date = extract_release_date_from_heading(line)
382
457
  dates[version] ||= date if version && date
383
458
  end
384
459
  end
385
460
 
461
+ def corrected_changelog_version(version, heading)
462
+ return version unless @metadata.respond_to?(:correct_changelog_version)
463
+
464
+ @metadata.correct_changelog_version(version, heading: heading) || version
465
+ end
466
+
386
467
  def parse_github_release_sections(cache_only: false, force_refresh: false)
387
468
  repo_uri = @metadata&.repo_uri(cache_only: cache_only, force_refresh: force_refresh)
388
469
  return {} unless repo_uri&.include?("github.com")
data/lib/gemstar/cli.rb CHANGED
@@ -5,7 +5,8 @@ module Gemstar
5
5
  class CLI < Thor
6
6
  package_name "gemstar"
7
7
 
8
- map "-D" => "diff"
8
+ map "-D" => "diff",
9
+ "--version" => "version"
9
10
 
10
11
  class_option :verbose, type: :boolean, default: false, desc: "Enable verbose output"
11
12
  class_option :lockfile, type: :string, default: "Gemfile.lock", desc: "Lockfile path"
@@ -15,7 +16,7 @@ module Gemstar
15
16
  method_option :to, type: :string, desc: "Git ref or lockfile"
16
17
  method_option :since, type: :string, desc: "Set --from to the latest commit before this relative time (for example: '3 weeks')"
17
18
  method_option :project, type: :string, desc: "Project directory or supported project file path"
18
- method_option :ecosystem, type: :string, desc: "Filter packages by ecosystem (all, gems, js)"
19
+ method_option :ecosystem, type: :string, desc: "Filter packages by ecosystem (all, gems, js, python, cargo)"
19
20
  method_option :format, type: :string, desc: "Output format (html or markdown)"
20
21
  method_option :output_file, type: :string, desc: "Output file path"
21
22
  method_option :debug_gem_regex, type: :string, desc: "Debug matching gems", hide: true
@@ -36,6 +37,11 @@ module Gemstar
36
37
  desc "cache SUBCOMMAND ...ARGS", "Manage gemstar caches"
37
38
  subcommand "cache", Gemstar::CacheCLI
38
39
 
40
+ desc "version", "Print gemstar version"
41
+ def version
42
+ puts Gemstar::VERSION
43
+ end
44
+
39
45
  # desc "pick", "Interactively cherry-pick and upgrade gems"
40
46
  # option :gem, type: :string, desc: "Gem name to cherry-pick"
41
47
  # def pick
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "command"
4
+ require_relative "../pypi_metadata"
5
+ require_relative "../crates_io_metadata"
4
6
  require "concurrent-ruby"
5
7
  require "tmpdir"
6
8
  require "pathname"
@@ -70,9 +72,9 @@ module Gemstar
70
72
  def normalize_ecosystem(value)
71
73
  normalized = value.to_s.strip.downcase
72
74
  return "all" if normalized.empty?
73
- return normalized if %w[all gems js].include?(normalized)
75
+ return normalized if %w[all gems js python cargo].include?(normalized)
74
76
 
75
- raise Thor::Error, "Unsupported ecosystem #{value.inspect}. Expected one of: all, gems, js"
77
+ raise Thor::Error, "Unsupported ecosystem #{value.inspect}. Expected one of: all, gems, js, python, cargo"
76
78
  end
77
79
 
78
80
  def normalize_since(value)
@@ -210,6 +212,11 @@ module Gemstar
210
212
  def metadata_for(package_state)
211
213
  if package_state[:package_scope] == "js"
212
214
  Gemstar::NpmMetadata.new(package_state[:name])
215
+ elsif package_state[:package_scope] == "python"
216
+ Gemstar::PyPIMetadata.new(package_state[:name])
217
+ elsif package_state[:package_scope] == "cargo"
218
+ package_name = package_state[:metadata_package_name] || package_state.dig(:source, :package_name) || package_state[:name]
219
+ Gemstar::CratesIOMetadata.new(package_name)
213
220
  else
214
221
  Gemstar::RubyGemsMetadata.new(package_state[:name])
215
222
  end
@@ -315,6 +322,10 @@ module Gemstar
315
322
  "importmap"
316
323
  when :package_lock
317
324
  "package-lock"
325
+ when :uv_lock
326
+ "uv.lock"
327
+ when :cargo_lock
328
+ "Cargo.lock"
318
329
  else
319
330
  package_state[:package_scope]
320
331
  end