ratatui_ruby-devtools 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.
- checksums.yaml +7 -0
- data/.builds/ruby-4.0.yml +38 -0
- data/.pre-commit-config.yaml +16 -0
- data/.rubocop.yml +8 -0
- data/AGENTS.md +72 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE +661 -0
- data/LICENSES/AGPL-3.0-or-later.txt +661 -0
- data/LICENSES/CC-BY-SA-4.0.txt +427 -0
- data/LICENSES/CC0-1.0.txt +121 -0
- data/LICENSES/MIT-0.txt +16 -0
- data/LICENSES/MIT.txt +18 -0
- data/README.md +199 -0
- data/REUSE.toml +18 -0
- data/Rakefile +13 -0
- data/bin/agent_rake +13 -0
- data/bin/announce +13 -0
- data/bin/console +14 -0
- data/bin/consolidate_md +13 -0
- data/bin/hbs +13 -0
- data/bin/setup +17 -0
- data/doc/contributors/documentation_style.md +121 -0
- data/doc/custom.css +22 -0
- data/exe/agent_rake +96 -0
- data/exe/announce +1120 -0
- data/exe/consolidate_md +246 -0
- data/exe/hbs +670 -0
- data/exe/scaffold +662 -0
- data/lib/ratatui_ruby/devtools/tasks/autodoc/examples.rb +133 -0
- data/lib/ratatui_ruby/devtools/tasks/autodoc/member.rb +116 -0
- data/lib/ratatui_ruby/devtools/tasks/autodoc/name.rb +33 -0
- data/lib/ratatui_ruby/devtools/tasks/autodoc.rake +21 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/cargo_lockfile.rb +38 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/changelog.rb +67 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/header.rb +43 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/history.rb +50 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/links.rb +78 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/manifest.rb +63 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/ruby_gem.rb +77 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/sem_ver.rb +63 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/unreleased_section.rb +75 -0
- data/lib/ratatui_ruby/devtools/tasks/bump.rake +80 -0
- data/lib/ratatui_ruby/devtools/tasks/cargo.rake +47 -0
- data/lib/ratatui_ruby/devtools/tasks/doc.rake +887 -0
- data/lib/ratatui_ruby/devtools/tasks/example_viewer.html.erb +172 -0
- data/lib/ratatui_ruby/devtools/tasks/license/headers_md.rb +276 -0
- data/lib/ratatui_ruby/devtools/tasks/license/headers_rb.rb +236 -0
- data/lib/ratatui_ruby/devtools/tasks/license/license_utils.rb +143 -0
- data/lib/ratatui_ruby/devtools/tasks/license/snippets_md.rb +353 -0
- data/lib/ratatui_ruby/devtools/tasks/license/snippets_rdoc.rb +186 -0
- data/lib/ratatui_ruby/devtools/tasks/license.rake +91 -0
- data/lib/ratatui_ruby/devtools/tasks/lint.rake +84 -0
- data/lib/ratatui_ruby/devtools/tasks/rdoc_config.rb +45 -0
- data/lib/ratatui_ruby/devtools/tasks/resources/build.yml.erb +54 -0
- data/lib/ratatui_ruby/devtools/tasks/resources/rubies.yml +7 -0
- data/lib/ratatui_ruby/devtools/tasks/reuse.rake +104 -0
- data/lib/ratatui_ruby/devtools/tasks/sourcehut.rake +94 -0
- data/lib/ratatui_ruby/devtools/tasks/test.rake +18 -0
- data/lib/ratatui_ruby/devtools/templates/.builds/ruby.yml.erb +47 -0
- data/lib/ratatui_ruby/devtools/templates/.gitignore.erb +18 -0
- data/lib/ratatui_ruby/devtools/templates/.pre-commit-config.yaml.erb +16 -0
- data/lib/ratatui_ruby/devtools/templates/.rubocop.yml.erb +8 -0
- data/lib/ratatui_ruby/devtools/templates/AGENTS.md.erb +65 -0
- data/lib/ratatui_ruby/devtools/templates/CHANGELOG.md.erb +18 -0
- data/lib/ratatui_ruby/devtools/templates/Gemfile.erb +32 -0
- data/lib/ratatui_ruby/devtools/templates/README.md.erb +127 -0
- data/lib/ratatui_ruby/devtools/templates/REUSE.toml.erb +33 -0
- data/lib/ratatui_ruby/devtools/templates/Rakefile.erb +29 -0
- data/lib/ratatui_ruby/devtools/templates/bin/console.erb +18 -0
- data/lib/ratatui_ruby/devtools/templates/bin/setup.erb +24 -0
- data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_architecture.md.erb +16 -0
- data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_testing.md.erb +49 -0
- data/lib/ratatui_ruby/devtools/templates/doc/custom.css.erb +24 -0
- data/lib/ratatui_ruby/devtools/templates/doc/getting_started/quickstart.md.erb +56 -0
- data/lib/ratatui_ruby/devtools/templates/doc/images/.gitkeep +0 -0
- data/lib/ratatui_ruby/devtools/templates/doc/index.md.erb +25 -0
- data/lib/ratatui_ruby/devtools/templates/exe/.gitkeep +0 -0
- data/lib/ratatui_ruby/devtools/templates/gemspec.erb +58 -0
- data/lib/ratatui_ruby/devtools/templates/mise.toml.erb +12 -0
- data/lib/ratatui_ruby/devtools/templates/tasks/example_viewer.html.erb +174 -0
- data/lib/ratatui_ruby/devtools/templates/tasks/resources/build.yml.erb +62 -0
- data/lib/ratatui_ruby/devtools/templates/tasks/resources/index.html.erb +46 -0
- data/lib/ratatui_ruby/devtools/templates/tasks/resources/rubies.yml.erb +9 -0
- data/lib/ratatui_ruby/devtools/templates/vendor/goodcop/base.yml +1047 -0
- data/lib/ratatui_ruby/devtools/version.rb +13 -0
- data/lib/ratatui_ruby/devtools.rb +137 -0
- data/mise.toml +7 -0
- data/sig/ratatui_ruby/devtools.rbs +15 -0
- data/vendor/goodcop/base.yml +1047 -0
- metadata +252 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
# Synchronizes code snippets with documentation.
|
|
9
|
+
#
|
|
10
|
+
# Documentation contains code examples. Source files change. Copy-pasting
|
|
11
|
+
# leads to stale examples. Tests pass but the README lies.
|
|
12
|
+
#
|
|
13
|
+
# This module scans markdown files for SYNC markers and replaces content
|
|
14
|
+
# with live source. The documentation stays accurate. No manual updates.
|
|
15
|
+
#
|
|
16
|
+
# Use it to keep README examples in sync with working code.
|
|
17
|
+
module Autodoc
|
|
18
|
+
# Synchronizes code snippets from source files into markdown.
|
|
19
|
+
#
|
|
20
|
+
# Markdown files contain embedded code examples. Maintaining them manually
|
|
21
|
+
# drifts from the source. This class scans for SYNC markers and injects
|
|
22
|
+
# live code from the referenced files.
|
|
23
|
+
#
|
|
24
|
+
# Use it to sync README.md examples with your actual implementation.
|
|
25
|
+
#
|
|
26
|
+
# === Example
|
|
27
|
+
#
|
|
28
|
+
# In your README.md:
|
|
29
|
+
# <!-- SYNC:START:examples/hello/app.rb:main -->
|
|
30
|
+
# ```ruby
|
|
31
|
+
# # This content gets replaced
|
|
32
|
+
# ```
|
|
33
|
+
# <!-- SYNC:END -->
|
|
34
|
+
#
|
|
35
|
+
# Then run:
|
|
36
|
+
# Autodoc::Examples.sync
|
|
37
|
+
#
|
|
38
|
+
class Examples
|
|
39
|
+
# Synchronize all README files in the repository.
|
|
40
|
+
def self.sync
|
|
41
|
+
new.sync
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Synchronize all README files.
|
|
45
|
+
#
|
|
46
|
+
# Scans for SYNC markers in markdown files and replaces content with
|
|
47
|
+
# source file snippets.
|
|
48
|
+
def sync
|
|
49
|
+
Dir.glob("{README.md,doc/**/*.md,examples/*/README.md}").each do |readme_path|
|
|
50
|
+
sync_readme(readme_path)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private def sync_readme(readme_path)
|
|
55
|
+
content = File.read(readme_path)
|
|
56
|
+
dir = File.dirname(readme_path)
|
|
57
|
+
|
|
58
|
+
new_content = content.gsub(/<!-- SYNC:START:([^ ]+) -->.*?<!-- SYNC:END -->/m) do
|
|
59
|
+
marker_info = $1
|
|
60
|
+
source_rel_path, segment_id = marker_info.split(":")
|
|
61
|
+
|
|
62
|
+
# Support both repo-root-relative paths (no leading ./) and file-relative paths
|
|
63
|
+
source_path = if source_rel_path.start_with?("./", "../")
|
|
64
|
+
File.join(dir, source_rel_path)
|
|
65
|
+
else
|
|
66
|
+
source_rel_path # Already relative to repo root
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
unless File.exist?(source_path)
|
|
70
|
+
warn "Warning: Source file not found: #{source_path}"
|
|
71
|
+
next $&
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
source_content = File.read(source_path)
|
|
75
|
+
extracted_content = if segment_id
|
|
76
|
+
extract_segment(source_content, segment_id, source_path)
|
|
77
|
+
else
|
|
78
|
+
source_content
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Detect language from extension
|
|
82
|
+
ext = File.extname(source_path).delete(".")
|
|
83
|
+
lang = (ext == "rb") ? "ruby" : ext
|
|
84
|
+
|
|
85
|
+
# Build replacement
|
|
86
|
+
"<!-- SYNC:START:#{marker_info} -->\n```#{lang}\n#{extracted_content}```\n<!-- SYNC:END -->"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if new_content != content
|
|
90
|
+
puts "Syncing #{readme_path}..."
|
|
91
|
+
File.write(readme_path, new_content)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Extracts a named segment from source content.
|
|
96
|
+
#
|
|
97
|
+
# Source files contain segment markers like <tt>[SYNC:START:main]</tt>.
|
|
98
|
+
# This method extracts the content between matching markers.
|
|
99
|
+
#
|
|
100
|
+
# [content] The source file content.
|
|
101
|
+
# [segment_id] The segment name to extract.
|
|
102
|
+
# [source_path] The source file path (for error messages).
|
|
103
|
+
def extract_segment(content, segment_id, source_path)
|
|
104
|
+
start_marker = /#\s*\[SYNC:START:#{segment_id}\]/
|
|
105
|
+
end_marker = /#\s*\[SYNC:END:#{segment_id}\]/
|
|
106
|
+
|
|
107
|
+
lines = content.lines
|
|
108
|
+
start_idx = lines.find_index { |l| l =~ start_marker }
|
|
109
|
+
end_idx = lines.find_index { |l| l =~ end_marker }
|
|
110
|
+
|
|
111
|
+
if start_idx && end_idx
|
|
112
|
+
"#{unindent(lines[(start_idx + 1)...end_idx].join).strip}\n"
|
|
113
|
+
else
|
|
114
|
+
warn "Warning: Segment '#{segment_id}' not found in #{source_path}"
|
|
115
|
+
content
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Removes common leading indentation from text.
|
|
120
|
+
#
|
|
121
|
+
# Code segments often have indentation from their context. This method
|
|
122
|
+
# strips the common prefix so the output looks clean.
|
|
123
|
+
#
|
|
124
|
+
# [text] The text to unindent.
|
|
125
|
+
def unindent(text)
|
|
126
|
+
lines = text.lines
|
|
127
|
+
return text if lines.empty?
|
|
128
|
+
|
|
129
|
+
indentation = lines.grep(/\S/).map { |l| l[/^\s*/].length }.min || 0
|
|
130
|
+
lines.map { |l| (l.length > indentation) ? l[indentation..-1] : "#{l.strip}\n" }.join
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
module Autodoc
|
|
9
|
+
# Member types for autodoc generation.
|
|
10
|
+
#
|
|
11
|
+
# Autodoc generates RBS types and RDoc comments for TUI factory methods.
|
|
12
|
+
# Each method type (delegate, factory, helper) has different documentation
|
|
13
|
+
# patterns. Writing these by hand is tedious and error-prone.
|
|
14
|
+
#
|
|
15
|
+
# These Data classes generate consistent RBS and RDoc output for each
|
|
16
|
+
# member type. Feed them method names. Get documentation.
|
|
17
|
+
module Member
|
|
18
|
+
# A method that delegates to an identically-named module method.
|
|
19
|
+
#
|
|
20
|
+
# Some instance methods simply call a module-level method. This class
|
|
21
|
+
# generates the RBS signature and RDoc comment for such methods.
|
|
22
|
+
#
|
|
23
|
+
# [name] The method name.
|
|
24
|
+
class Delegate < Data.define(:name)
|
|
25
|
+
# Generates an RBS type signature for this delegate.
|
|
26
|
+
#
|
|
27
|
+
# Autodoc writes .rbs files. Each method needs a signature. Delegates
|
|
28
|
+
# forward all arguments, so they use a generic variadic signature.
|
|
29
|
+
def rbs
|
|
30
|
+
" def #{name}: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Generates RDoc comment lines for this delegate.
|
|
34
|
+
#
|
|
35
|
+
# Autodoc writes method documentation. Each method needs a comment.
|
|
36
|
+
# Delegates describe forwarding to the module method. This returns
|
|
37
|
+
# the correctly-formatted comment lines.
|
|
38
|
+
def rdoc
|
|
39
|
+
[
|
|
40
|
+
" # :method: #{name}",
|
|
41
|
+
" # :call-seq: #{name}(*args, **kwargs, &block)",
|
|
42
|
+
" #",
|
|
43
|
+
" # Delegates to RatatuiRuby.#{name}.",
|
|
44
|
+
" #",
|
|
45
|
+
]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A factory method that creates instances of a widget class.
|
|
50
|
+
#
|
|
51
|
+
# Factory methods like <tt>paragraph</tt> create <tt>Paragraph.new</tt>.
|
|
52
|
+
# This class generates the RBS signature and RDoc comment.
|
|
53
|
+
#
|
|
54
|
+
# [name] The method name.
|
|
55
|
+
# [const_name] The constant name (e.g., <tt>Paragraph</tt>).
|
|
56
|
+
class Factory < Data.define(:name, :const_name)
|
|
57
|
+
# Generates an RBS type signature for this factory.
|
|
58
|
+
#
|
|
59
|
+
# Autodoc writes .rbs files. Each method needs a signature. Factories
|
|
60
|
+
# forward all arguments to constructors, so they use a generic variadic
|
|
61
|
+
# signature.
|
|
62
|
+
def rbs
|
|
63
|
+
" def #{name}: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Generates RDoc comment lines for this factory.
|
|
67
|
+
#
|
|
68
|
+
# Autodoc writes method documentation. Each method needs a comment.
|
|
69
|
+
# Factories describe creating a widget instance. This returns the
|
|
70
|
+
# correctly-formatted comment lines.
|
|
71
|
+
def rdoc
|
|
72
|
+
[
|
|
73
|
+
" # :method: #{name}",
|
|
74
|
+
" # :call-seq: #{name}(*args, **kwargs, &block)",
|
|
75
|
+
" #",
|
|
76
|
+
" # Factory for RatatuiRuby::#{const_name}.new.",
|
|
77
|
+
" #",
|
|
78
|
+
]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A helper method that wraps a class method.
|
|
83
|
+
#
|
|
84
|
+
# Helper methods call class methods with simplified signatures. This
|
|
85
|
+
# class generates the RBS signature and RDoc comment.
|
|
86
|
+
#
|
|
87
|
+
# [name] The method name.
|
|
88
|
+
# [class_method] The class method being wrapped.
|
|
89
|
+
# [const_name] The constant name.
|
|
90
|
+
class Helper < Data.define(:name, :class_method, :const_name)
|
|
91
|
+
# Generates an RBS type signature for this helper.
|
|
92
|
+
#
|
|
93
|
+
# Autodoc writes .rbs files. Each method needs a signature. Helpers
|
|
94
|
+
# forward all arguments to class methods, so they use a generic variadic
|
|
95
|
+
# signature.
|
|
96
|
+
def rbs
|
|
97
|
+
" def #{name}: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Generates RDoc comment lines for this helper.
|
|
101
|
+
#
|
|
102
|
+
# Autodoc writes method documentation. Each method needs a comment.
|
|
103
|
+
# Helpers describe calling a class method. This returns the
|
|
104
|
+
# correctly-formatted comment lines.
|
|
105
|
+
def rdoc
|
|
106
|
+
[
|
|
107
|
+
" # :method: #{name}",
|
|
108
|
+
" # :call-seq: #{name}(*args, **kwargs, &block)",
|
|
109
|
+
" #",
|
|
110
|
+
" # Helper for RatatuiRuby::#{const_name}.#{class_method}.",
|
|
111
|
+
" #",
|
|
112
|
+
]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
module Autodoc
|
|
9
|
+
# Wraps a name string with case conversion utilities.
|
|
10
|
+
#
|
|
11
|
+
# Ruby uses snake_case. Constants use PascalCase. Converting between them
|
|
12
|
+
# by hand invites typos. This class handles the conversion.
|
|
13
|
+
#
|
|
14
|
+
# [string] The name string.
|
|
15
|
+
class Name < Data.define(:string)
|
|
16
|
+
# Converts the name to snake_case.
|
|
17
|
+
#
|
|
18
|
+
# === Example
|
|
19
|
+
#
|
|
20
|
+
# Autodoc::Name.new("BarChart").snake # => "bar_chart"
|
|
21
|
+
def snake
|
|
22
|
+
string.to_s
|
|
23
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
24
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
25
|
+
.downcase
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns the original string.
|
|
29
|
+
def to_s
|
|
30
|
+
string.to_s
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
require_relative "autodoc/examples"
|
|
9
|
+
|
|
10
|
+
namespace :autodoc do
|
|
11
|
+
desc "Update all automatically generated documentation"
|
|
12
|
+
task all: [:examples]
|
|
13
|
+
|
|
14
|
+
desc "Sync code snippets in example READMEs with source files"
|
|
15
|
+
task :examples do
|
|
16
|
+
Autodoc::Examples.sync
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
desc "Update all automatically generated documentation"
|
|
21
|
+
task autodoc: "autodoc:all"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
# Refreshes Cargo.lock after version changes.
|
|
9
|
+
#
|
|
10
|
+
# Rust crates have lockfiles that pin dependency versions. After updating
|
|
11
|
+
# Cargo.toml, the lockfile becomes stale. Running cargo update fixes it.
|
|
12
|
+
#
|
|
13
|
+
# This class wraps the lockfile refresh operation. It runs cargo update
|
|
14
|
+
# in the crate directory. Use it after bumping Rust extension versions.
|
|
15
|
+
#
|
|
16
|
+
# [path] The path to the Cargo.lock file.
|
|
17
|
+
# [dir] The directory containing the Cargo.toml.
|
|
18
|
+
# [name] The crate name to update.
|
|
19
|
+
class CargoLockfile < Data.define(:path, :dir, :name)
|
|
20
|
+
# Checks whether the lockfile exists on disk.
|
|
21
|
+
#
|
|
22
|
+
# Pure Ruby gems have no Cargo.lock. Refreshing a missing file fails. Check
|
|
23
|
+
# this before calling refresh to avoid errors.
|
|
24
|
+
def exists?
|
|
25
|
+
File.exist?(path)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Refreshes the lockfile by running cargo update.
|
|
29
|
+
#
|
|
30
|
+
# Runs <tt>cargo update -p {name} --offline</tt> in the crate directory.
|
|
31
|
+
def refresh
|
|
32
|
+
return unless exists?
|
|
33
|
+
|
|
34
|
+
Dir.chdir(dir) do
|
|
35
|
+
system("cargo update -p #{name} --offline")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
require_relative "links"
|
|
9
|
+
require_relative "unreleased_section"
|
|
10
|
+
require_relative "history"
|
|
11
|
+
require_relative "header"
|
|
12
|
+
|
|
13
|
+
# Manages the project's CHANGELOG.md file.
|
|
14
|
+
#
|
|
15
|
+
# Changelogs track user-facing changes. During a release, the Unreleased
|
|
16
|
+
# section becomes a versioned section. Links update. The Unreleased section
|
|
17
|
+
# resets. Doing this by hand invites errors.
|
|
18
|
+
#
|
|
19
|
+
# This class orchestrates the changelog update. It parses the sections, moves
|
|
20
|
+
# content, updates links, and writes the result. One call. Clean changelog.
|
|
21
|
+
#
|
|
22
|
+
# Use it during version bumps to update the release notes.
|
|
23
|
+
class Changelog
|
|
24
|
+
# Creates a new Changelog manager.
|
|
25
|
+
#
|
|
26
|
+
# [path] The path to the changelog file. Defaults to <tt>CHANGELOG.md</tt>.
|
|
27
|
+
def initialize(path: "CHANGELOG.md")
|
|
28
|
+
@path = path
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Releases a new version in the changelog.
|
|
32
|
+
#
|
|
33
|
+
# Moves unreleased changes to a dated version section. Resets the Unreleased
|
|
34
|
+
# section. Updates the comparison links.
|
|
35
|
+
#
|
|
36
|
+
# [new_version] The SemVer or version string to release.
|
|
37
|
+
def release(new_version)
|
|
38
|
+
content = File.read(@path)
|
|
39
|
+
|
|
40
|
+
header = Header.parse(content)
|
|
41
|
+
unreleased = UnreleasedSection.parse(content)
|
|
42
|
+
links = Links.from_markdown(content)
|
|
43
|
+
|
|
44
|
+
raise "Could not parse CHANGELOG.md" unless header && unreleased && links
|
|
45
|
+
|
|
46
|
+
history = History.parse(content, header.length, unreleased.to_s.length, links.to_s)
|
|
47
|
+
|
|
48
|
+
links.release(new_version)
|
|
49
|
+
history.add(unreleased.as_version(new_version))
|
|
50
|
+
|
|
51
|
+
File.write(@path, "#{header}#{UnreleasedSection.fresh}\n\n#{history}\n#{links}")
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Generates a commit message for the release.
|
|
56
|
+
#
|
|
57
|
+
# Extracts the unreleased changes and formats them for a commit body.
|
|
58
|
+
#
|
|
59
|
+
# [version] The version being released.
|
|
60
|
+
def commit_message(version)
|
|
61
|
+
content = File.read(@path)
|
|
62
|
+
unreleased = UnreleasedSection.parse(content)
|
|
63
|
+
return nil unless unreleased
|
|
64
|
+
|
|
65
|
+
"chore: release v#{version}\n\n#{unreleased.commit_body}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
# Manages the header section of a changelog.
|
|
9
|
+
#
|
|
10
|
+
# Changelogs start with a header: title, description, format reference. During
|
|
11
|
+
# updates, this section stays unchanged. Extracting it ensures safe rewrites.
|
|
12
|
+
#
|
|
13
|
+
# This class parses the header from changelog markdown. It preserves it
|
|
14
|
+
# during modifications.
|
|
15
|
+
class Header
|
|
16
|
+
# Regex to match everything before the Unreleased section.
|
|
17
|
+
PATTERN = /^(.*?)(?=## \[Unreleased\])/m
|
|
18
|
+
|
|
19
|
+
# Parses the header from changelog content.
|
|
20
|
+
#
|
|
21
|
+
# [content] The full changelog text.
|
|
22
|
+
def self.parse(content)
|
|
23
|
+
match = content.match(PATTERN)
|
|
24
|
+
new(match[1]) if match
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Creates a new Header.
|
|
28
|
+
#
|
|
29
|
+
# [content] The raw header text.
|
|
30
|
+
def initialize(content)
|
|
31
|
+
@content = content.dup
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns the byte length of the header.
|
|
35
|
+
def length
|
|
36
|
+
@content.length
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Returns the header as a string.
|
|
40
|
+
def to_s
|
|
41
|
+
@content
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
# Manages the versioned history section of a changelog.
|
|
9
|
+
#
|
|
10
|
+
# Changelogs contain past version entries below the Unreleased section. During
|
|
11
|
+
# a release, new version entries prepend to this history. Manipulating it
|
|
12
|
+
# manually risks corruption.
|
|
13
|
+
#
|
|
14
|
+
# This class extracts history from the changelog. It prepends new version
|
|
15
|
+
# entries. It serializes back to markdown.
|
|
16
|
+
#
|
|
17
|
+
# Use it during release preparation.
|
|
18
|
+
class History
|
|
19
|
+
# Parses the history section from changelog content.
|
|
20
|
+
#
|
|
21
|
+
# [content] The full changelog text.
|
|
22
|
+
# [header_length] Length of the header section.
|
|
23
|
+
# [unreleased_length] Length of the Unreleased section.
|
|
24
|
+
# [links_text] The links section text (used as end marker).
|
|
25
|
+
def self.parse(content, header_length, unreleased_length, links_text)
|
|
26
|
+
start = header_length + unreleased_length
|
|
27
|
+
text = "#{content[start...(content.index(links_text))].strip}\n"
|
|
28
|
+
new(text)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Creates a new History.
|
|
32
|
+
#
|
|
33
|
+
# [content] The raw history text.
|
|
34
|
+
def initialize(content)
|
|
35
|
+
@content = content.dup
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Adds a new versioned section to the beginning of history.
|
|
39
|
+
#
|
|
40
|
+
# [section] The version section text to prepend.
|
|
41
|
+
def add(section)
|
|
42
|
+
@content = "#{"#{section}\n\n#{@content}".strip}\n"
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns the history as a string.
|
|
47
|
+
def to_s
|
|
48
|
+
@content
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
# Manages the version comparison links at the botton of the changelog.
|
|
9
|
+
#
|
|
10
|
+
# Release automation needs to update links. Manually calculating git diff URLs
|
|
11
|
+
# for every release is tedious and error-prone. SourceHut does not have
|
|
12
|
+
# standard comparison views, complicating matters further.
|
|
13
|
+
#
|
|
14
|
+
# This class manages the collection of links. It parses them from the markdown.
|
|
15
|
+
# It generates the correct tree links for SourceHut. It properly shifts the
|
|
16
|
+
# "Unreleased" pointer.
|
|
17
|
+
#
|
|
18
|
+
# Use it to update the changelog during a release.
|
|
19
|
+
class Links
|
|
20
|
+
# Regex to match the markdown links section.
|
|
21
|
+
#
|
|
22
|
+
# Changelogs end with reference-style links. Scanning the whole document is
|
|
23
|
+
# wasteful. This pattern finds where the links begin.
|
|
24
|
+
PATTERN = /^(\[Unreleased\]: .*)$/m
|
|
25
|
+
|
|
26
|
+
# Regex to extract the base URL from the Unreleased link.
|
|
27
|
+
#
|
|
28
|
+
# New version links derive from the Unreleased URL structure. Parsing the
|
|
29
|
+
# URL components enables programmatic link generation.
|
|
30
|
+
UNRELEASED_PATTERN = %r{^\[Unreleased\]: (.*?/refs/)HEAD$}
|
|
31
|
+
|
|
32
|
+
# Creates a Links object from the full markdown content.
|
|
33
|
+
#
|
|
34
|
+
# [content] String. The full text of the changelog.
|
|
35
|
+
def self.from_markdown(content)
|
|
36
|
+
match = content.match(PATTERN)
|
|
37
|
+
return unless match
|
|
38
|
+
|
|
39
|
+
new(match[1].strip)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns the raw text of the links.
|
|
43
|
+
attr_reader :text
|
|
44
|
+
|
|
45
|
+
# Creates a new Links object.
|
|
46
|
+
#
|
|
47
|
+
# [text] String. The raw text of the links section.
|
|
48
|
+
def initialize(text)
|
|
49
|
+
@text = text.dup
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Releases a new version.
|
|
53
|
+
#
|
|
54
|
+
# Updates the "Unreleased" link to point to the new head. Adds a new link for
|
|
55
|
+
# the just-released version pointing to its specific tag.
|
|
56
|
+
#
|
|
57
|
+
# [version] String. The new version number (e.g., <tt>"0.5.0"</tt>).
|
|
58
|
+
def release(version)
|
|
59
|
+
return unless base_url
|
|
60
|
+
|
|
61
|
+
new_unreleased = "[Unreleased]: #{base_url}HEAD" # .../HEAD
|
|
62
|
+
new_version_link = "[#{version}]: #{base_url}v#{version}" # .../v1.0.0
|
|
63
|
+
|
|
64
|
+
@text.sub!(UNRELEASED_PATTERN, "#{new_unreleased}\n#{new_version_link}")
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Returns the string representation of the links.
|
|
69
|
+
def to_s
|
|
70
|
+
@text
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The base URL for the repository's references.
|
|
74
|
+
private def base_url
|
|
75
|
+
match = @text.match(UNRELEASED_PATTERN)
|
|
76
|
+
match[1] if match
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
# Represents a file that contains a version number.
|
|
9
|
+
#
|
|
10
|
+
# Gems have version numbers in multiple places: version.rb, Cargo.toml, etc.
|
|
11
|
+
# Finding and updating them by hand risks inconsistency. One file says 1.2.3,
|
|
12
|
+
# another says 1.2.2.
|
|
13
|
+
#
|
|
14
|
+
# This class wraps a file path with a regex pattern. It reads the current
|
|
15
|
+
# version and writes new versions. Use lookaround patterns to match precisely.
|
|
16
|
+
#
|
|
17
|
+
# [path] The file path.
|
|
18
|
+
# [pattern] A regex with lookarounds to match the version string.
|
|
19
|
+
# [primary] Whether this is the primary source of truth.
|
|
20
|
+
#
|
|
21
|
+
# === Example
|
|
22
|
+
#
|
|
23
|
+
# manifest = Manifest.new(
|
|
24
|
+
# path: "lib/my_gem/version.rb",
|
|
25
|
+
# pattern: /(?<=VERSION = ")[^"]+(?=")/,
|
|
26
|
+
# primary: true
|
|
27
|
+
# )
|
|
28
|
+
# manifest.version.to_s # => "1.2.3"
|
|
29
|
+
#
|
|
30
|
+
class Manifest < Data.define(:path, :pattern, :primary)
|
|
31
|
+
# Creates a new Manifest.
|
|
32
|
+
#
|
|
33
|
+
# [path] The file path.
|
|
34
|
+
# [pattern] A regex with lookarounds to match the version string.
|
|
35
|
+
# [primary] Whether this is the primary source of truth.
|
|
36
|
+
def initialize(path:, pattern:, primary: false)
|
|
37
|
+
super
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Reads the file content.
|
|
41
|
+
def read
|
|
42
|
+
File.read(path)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns the current version from this manifest.
|
|
46
|
+
def version
|
|
47
|
+
content = read
|
|
48
|
+
match = content.match(pattern)
|
|
49
|
+
raise "Version missing in manifest #{path}" unless match
|
|
50
|
+
|
|
51
|
+
SemVer.parse(match[0])
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Writes a new version to this manifest.
|
|
55
|
+
#
|
|
56
|
+
# [version] The SemVer to write.
|
|
57
|
+
def write(version)
|
|
58
|
+
return unless File.exist?(path)
|
|
59
|
+
|
|
60
|
+
new_content = read.gsub(pattern, version.to_s)
|
|
61
|
+
File.write(path, new_content)
|
|
62
|
+
end
|
|
63
|
+
end
|