ratatui_ruby-devtools 0.2.1 → 0.3.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 +4 -4
- data/.rubocop.yml +1 -1
- data/AGENTS.md +1 -1
- data/CHANGELOG.md +53 -7
- data/README.md +49 -21
- data/Rakefile +1 -1
- data/bin/setup +20 -6
- data/bin/setup.ps1 +74 -0
- data/doc/contributors/documentation_style.md +3 -4
- data/exe/scaffold +8 -342
- data/lib/ratatui_ruby/devtools/scaffold/scaffold_config.rb +67 -0
- data/lib/ratatui_ruby/devtools/scaffold/template_renderer.rb +282 -0
- data/lib/ratatui_ruby/devtools/task_scope.rb +24 -0
- data/lib/ratatui_ruby/devtools/tasks/autodoc_tasks.rb +31 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/bump_workflow.rb +71 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/changelog.rb +60 -3
- data/lib/ratatui_ruby/devtools/tasks/bump/github_project.rb +26 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/history.rb +3 -3
- data/lib/ratatui_ruby/devtools/tasks/bump/links.rb +39 -24
- data/lib/ratatui_ruby/devtools/tasks/bump/manifest.rb +9 -17
- data/lib/ratatui_ruby/devtools/tasks/bump/no_crate.rb +19 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/no_links.rb +22 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/patch_release.rb +25 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/release_branch.rb +17 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/release_from_trunk.rb +48 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/repository.rb +143 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/ruby_gem.rb +65 -59
- data/lib/ratatui_ruby/devtools/tasks/bump/rust_crate.rb +42 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/sem_ver.rb +127 -10
- data/lib/ratatui_ruby/devtools/tasks/bump/source_hut_project.rb +26 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/unbranched_release.rb +20 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/unreleased_section.rb +19 -1
- data/lib/ratatui_ruby/devtools/tasks/bump_tasks.rb +47 -0
- data/lib/ratatui_ruby/devtools/tasks/cargo_tasks.rb +57 -0
- data/lib/ratatui_ruby/devtools/tasks/doc/documentation.rb +59 -0
- data/lib/ratatui_ruby/devtools/tasks/doc/link/file_url.rb +30 -0
- data/lib/ratatui_ruby/devtools/tasks/doc/link/relative_path.rb +61 -0
- data/lib/ratatui_ruby/devtools/tasks/doc/link/web_url.rb +55 -0
- data/lib/ratatui_ruby/devtools/tasks/doc/link.rb +52 -0
- data/lib/ratatui_ruby/devtools/tasks/doc/link_audit.rb +116 -0
- data/lib/ratatui_ruby/devtools/tasks/doc/problem.rb +40 -0
- data/lib/ratatui_ruby/devtools/tasks/doc/source_file.rb +93 -0
- data/lib/ratatui_ruby/devtools/tasks/doc_tasks.rb +937 -0
- data/lib/ratatui_ruby/devtools/tasks/license/blame.rb +72 -0
- data/lib/ratatui_ruby/devtools/tasks/license/changed_files.rb +34 -0
- data/lib/ratatui_ruby/devtools/tasks/license/contribution.rb +12 -0
- data/lib/ratatui_ruby/devtools/tasks/license/copyright.rb +47 -0
- data/lib/ratatui_ruby/devtools/tasks/license/directory_licensing.rb +42 -0
- data/lib/ratatui_ruby/devtools/tasks/license/existing_header.rb +21 -0
- data/lib/ratatui_ruby/devtools/tasks/license/header_revision.rb +45 -0
- data/lib/ratatui_ruby/devtools/tasks/license/licensed_file.rb +39 -0
- data/lib/ratatui_ruby/devtools/tasks/license/licensing.rb +35 -0
- data/lib/ratatui_ruby/devtools/tasks/license/markdown_document.rb +23 -0
- data/lib/ratatui_ruby/devtools/tasks/license/markdown_files.rb +31 -0
- data/lib/ratatui_ruby/devtools/tasks/license/markdown_header_syntax.rb +104 -0
- data/lib/ratatui_ruby/devtools/tasks/license/markdown_snippet_syntax.rb +159 -0
- data/lib/ratatui_ruby/devtools/tasks/license/missing_header.rb +18 -0
- data/lib/ratatui_ruby/devtools/tasks/license/rbs_files.rb +27 -0
- data/lib/ratatui_ruby/devtools/tasks/license/rbs_header_syntax.rb +31 -0
- data/lib/ratatui_ruby/devtools/tasks/license/rdoc_comment.rb +51 -0
- data/lib/ratatui_ruby/devtools/tasks/license/rdoc_examples.rb +41 -0
- data/lib/ratatui_ruby/devtools/tasks/license/rdoc_snippet_syntax.rb +119 -0
- data/lib/ratatui_ruby/devtools/tasks/license/renewal.rb +17 -0
- data/lib/ratatui_ruby/devtools/tasks/license/reuse_annotated_file.rb +12 -0
- data/lib/ratatui_ruby/devtools/tasks/license/reuse_tool.rb +34 -0
- data/lib/ratatui_ruby/devtools/tasks/license/ruby_comment_header.rb +49 -0
- data/lib/ratatui_ruby/devtools/tasks/license/ruby_files.rb +36 -0
- data/lib/ratatui_ruby/devtools/tasks/license/ruby_header_syntax.rb +39 -0
- data/lib/ratatui_ruby/devtools/tasks/license/rust_files.rb +27 -0
- data/lib/ratatui_ruby/devtools/tasks/license/rust_header_syntax.rb +59 -0
- data/lib/ratatui_ruby/devtools/tasks/license/snippet.rb +44 -0
- data/lib/ratatui_ruby/devtools/tasks/license/snippet_file.rb +29 -0
- data/lib/ratatui_ruby/devtools/tasks/license/snippet_opening.rb +39 -0
- data/lib/ratatui_ruby/devtools/tasks/license/snippet_revision.rb +75 -0
- data/lib/ratatui_ruby/devtools/tasks/license/spdx_header.rb +42 -0
- data/lib/ratatui_ruby/devtools/tasks/license/unknown_file.rb +12 -0
- data/lib/ratatui_ruby/devtools/tasks/license/unlicensed_files.rb +69 -0
- data/lib/ratatui_ruby/devtools/tasks/license/wrapped_snippet.rb +30 -0
- data/lib/ratatui_ruby/devtools/tasks/license_tasks.rb +71 -0
- data/lib/ratatui_ruby/devtools/tasks/lint/gem_lockfile.rb +29 -0
- data/lib/ratatui_ruby/devtools/tasks/lint/supported_rubies.rb +18 -0
- data/lib/ratatui_ruby/devtools/tasks/lint_tasks.rb +100 -0
- data/lib/ratatui_ruby/devtools/tasks/release/platform_gem.rb +21 -12
- data/lib/ratatui_ruby/devtools/tasks/release/versioned_binary.rb +1 -1
- data/lib/ratatui_ruby/devtools/tasks/release_tasks.rb +80 -0
- data/lib/ratatui_ruby/devtools/tasks/reuse_tasks.rb +39 -0
- data/lib/ratatui_ruby/devtools/tasks/{test.rake → test_tasks.rb} +13 -4
- data/lib/ratatui_ruby/devtools/tasks/website/hosted_source_tree.rb +24 -0
- data/lib/ratatui_ruby/devtools/tasks/website/index_page.rb +1 -4
- data/lib/ratatui_ruby/devtools/tasks/website/source_tree.rb +63 -0
- data/lib/ratatui_ruby/devtools/tasks/website/versioned_documentation.rb +14 -13
- data/lib/ratatui_ruby/devtools/tasks/website_tasks.rb +49 -0
- data/lib/ratatui_ruby/devtools/templates/.github/workflows/build-gems.yml.erb +7 -1
- data/lib/ratatui_ruby/devtools/templates/.github/workflows/ci.yml.erb +2 -2
- data/lib/ratatui_ruby/devtools/templates/.gitignore.erb +2 -1
- data/lib/ratatui_ruby/devtools/templates/.rubocop.yml.erb +1 -1
- data/lib/ratatui_ruby/devtools/templates/AGENTS.md.erb +1 -1
- data/lib/ratatui_ruby/devtools/templates/CHANGELOG.md.erb +3 -1
- data/lib/ratatui_ruby/devtools/templates/CONTRIBUTING.md.erb +3 -3
- data/lib/ratatui_ruby/devtools/templates/Gemfile.erb +2 -3
- data/lib/ratatui_ruby/devtools/templates/README.md.erb +7 -7
- data/lib/ratatui_ruby/devtools/templates/REUSE.toml.erb +1 -1
- data/lib/ratatui_ruby/devtools/templates/bin/setup.erb +5 -1
- data/lib/ratatui_ruby/devtools/templates/bin/setup.ps1.erb +5 -1
- data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_architecture.md.erb +2 -2
- data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_testing.md.erb +3 -3
- data/lib/ratatui_ruby/devtools/templates/doc/getting_started/quickstart.md.erb +4 -4
- data/lib/ratatui_ruby/devtools/templates/ext/Cargo.toml.erb +1 -1
- data/lib/ratatui_ruby/devtools/templates/gemspec.erb +1 -1
- data/lib/ratatui_ruby/devtools/templates/mise.toml.erb +3 -3
- data/lib/ratatui_ruby/devtools/version.rb +1 -1
- data/lib/ratatui_ruby/devtools.rb +54 -71
- data/mise.toml +2 -2
- data/sig/ratatui_ruby/devtools.rbs +16 -1
- metadata +93 -34
- data/.builds/ruby-4.0.yml +0 -38
- data/lib/ratatui_ruby/devtools/tasks/autodoc.rake +0 -21
- data/lib/ratatui_ruby/devtools/tasks/bump.rake +0 -80
- data/lib/ratatui_ruby/devtools/tasks/cargo.rake +0 -47
- data/lib/ratatui_ruby/devtools/tasks/doc.rake +0 -887
- data/lib/ratatui_ruby/devtools/tasks/license/headers_md.rb +0 -276
- data/lib/ratatui_ruby/devtools/tasks/license/headers_rb.rb +0 -236
- data/lib/ratatui_ruby/devtools/tasks/license/license_utils.rb +0 -143
- data/lib/ratatui_ruby/devtools/tasks/license/snippets_md.rb +0 -353
- data/lib/ratatui_ruby/devtools/tasks/license/snippets_rdoc.rb +0 -186
- data/lib/ratatui_ruby/devtools/tasks/license.rake +0 -91
- data/lib/ratatui_ruby/devtools/tasks/lint.rake +0 -80
- data/lib/ratatui_ruby/devtools/tasks/release.rake +0 -87
- data/lib/ratatui_ruby/devtools/tasks/resources/rubies.yml +0 -7
- data/lib/ratatui_ruby/devtools/tasks/reuse.rake +0 -104
- data/lib/ratatui_ruby/devtools/tasks/website.rake +0 -37
- data/lib/ratatui_ruby/devtools/templates/.builds/ruby.yml.erb +0 -47
- data/lib/ratatui_ruby/devtools/templates/tasks/resources/index.html.erb +0 -45
- data/lib/ratatui_ruby/devtools/templates/tasks/resources/rubies.yml.erb +0 -8
|
@@ -0,0 +1,282 @@
|
|
|
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 "erb"
|
|
9
|
+
require "fileutils"
|
|
10
|
+
|
|
11
|
+
# Renders ERB templates into the target directory.
|
|
12
|
+
#
|
|
13
|
+
# Templates use ERB to inject gem-specific values. This class handles the
|
|
14
|
+
# rendering and file creation with proper directory structure.
|
|
15
|
+
class TemplateRenderer
|
|
16
|
+
# Creates a new renderer with config and paths.
|
|
17
|
+
#
|
|
18
|
+
# [config] The ScaffoldConfig with gem parameters.
|
|
19
|
+
# [templates_dir] Path to the ERB templates.
|
|
20
|
+
# [output_dir] Path where rendered files are written.
|
|
21
|
+
# [update_mode] Whether to ask before overwriting a file that differs.
|
|
22
|
+
# [input] Where the answers to those questions come from.
|
|
23
|
+
def initialize(config, templates_dir, output_dir, update_mode: false, input: $stdin)
|
|
24
|
+
@config = config
|
|
25
|
+
@templates_dir = templates_dir
|
|
26
|
+
@output_dir = output_dir
|
|
27
|
+
@update_mode = update_mode
|
|
28
|
+
@input = input
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Renders all templates into the output directory.
|
|
32
|
+
def render_all
|
|
33
|
+
FileUtils.mkdir_p(@output_dir)
|
|
34
|
+
|
|
35
|
+
render_template(".gitignore.erb", ".gitignore")
|
|
36
|
+
render_template(".pre-commit-config.yaml.erb", ".pre-commit-config.yaml")
|
|
37
|
+
render_template(".rubocop.yml.erb", ".rubocop.yml")
|
|
38
|
+
render_template("AGENTS.md.erb", "AGENTS.md")
|
|
39
|
+
render_template("CHANGELOG.md.erb", "CHANGELOG.md") unless @update_mode
|
|
40
|
+
render_template("CONTRIBUTING.md.erb", "CONTRIBUTING.md")
|
|
41
|
+
render_template("CODE_OF_CONDUCT.md.erb", "CODE_OF_CONDUCT.md")
|
|
42
|
+
render_template("Gemfile.erb", "Gemfile")
|
|
43
|
+
render_template("README.md.erb", "README.md")
|
|
44
|
+
render_template("REUSE.toml.erb", "REUSE.toml")
|
|
45
|
+
render_template("Rakefile.erb", "Rakefile")
|
|
46
|
+
render_template("gemspec.erb", "#{@config.gem_name}.gemspec")
|
|
47
|
+
render_template("mise.toml.erb", "mise.toml")
|
|
48
|
+
|
|
49
|
+
# bin/ scripts
|
|
50
|
+
render_template("bin/setup.erb", "bin/setup", executable: true)
|
|
51
|
+
render_template("bin/setup.ps1.erb", "bin/setup.ps1")
|
|
52
|
+
render_template("bin/console.erb", "bin/console", executable: true)
|
|
53
|
+
|
|
54
|
+
# GitHub Actions CI workflow
|
|
55
|
+
render_template(".github/workflows/ci.yml.erb", ".github/workflows/ci.yml")
|
|
56
|
+
|
|
57
|
+
# GitHub Actions build-gems workflow (Rust only)
|
|
58
|
+
render_template(".github/workflows/build-gems.yml.erb", ".github/workflows/build-gems.yml") if @config.has_rust
|
|
59
|
+
|
|
60
|
+
# task resources
|
|
61
|
+
copy_task_resources
|
|
62
|
+
|
|
63
|
+
# doc files (RDoc styling)
|
|
64
|
+
copy_doc_files
|
|
65
|
+
|
|
66
|
+
# lib/ structure
|
|
67
|
+
create_lib_structure
|
|
68
|
+
|
|
69
|
+
# Rust extension (if enabled)
|
|
70
|
+
create_rust_extension if @config.has_rust
|
|
71
|
+
|
|
72
|
+
puts "Scaffolded #{@config.gem_name} in #{@output_dir}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private def render_template(template_name, output_name, executable: false)
|
|
76
|
+
template_path = File.join(@templates_dir, template_name)
|
|
77
|
+
output_path = File.join(@output_dir, output_name)
|
|
78
|
+
|
|
79
|
+
FileUtils.mkdir_p(File.dirname(output_path))
|
|
80
|
+
|
|
81
|
+
unless File.exist?(template_path)
|
|
82
|
+
warn " WARNING: Template not found: #{template_name}"
|
|
83
|
+
return
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
content = if template_name.end_with?(".erb")
|
|
87
|
+
ERB.new(File.read(template_path), trim_mode: "-").result(binding_for_config)
|
|
88
|
+
else
|
|
89
|
+
File.read(template_path)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
if @update_mode && File.exist?(output_path)
|
|
93
|
+
existing = File.read(output_path)
|
|
94
|
+
if existing == content
|
|
95
|
+
puts " Unchanged #{output_name}"
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
puts " Conflict #{output_name}"
|
|
99
|
+
show_diff(output_path, content)
|
|
100
|
+
print " Overwrite #{output_name}? [y/N] "
|
|
101
|
+
answer = @input.gets&.strip
|
|
102
|
+
unless answer&.match?(/^[Yy]/)
|
|
103
|
+
puts " Skipped #{output_name}"
|
|
104
|
+
return
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
File.write(output_path, content)
|
|
109
|
+
FileUtils.chmod(0o755, output_path) if executable
|
|
110
|
+
puts " Created #{output_name}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private def show_diff(existing_path, new_content)
|
|
114
|
+
require "tempfile"
|
|
115
|
+
Tempfile.create(["scaffold", File.extname(existing_path)]) do |tmp|
|
|
116
|
+
tmp.write(new_content)
|
|
117
|
+
tmp.flush
|
|
118
|
+
system("git", "--no-pager", "diff", "--no-index", "--color", existing_path, tmp.path)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private def copy_task_resources
|
|
123
|
+
# Render example_viewer.html.erb to tasks/
|
|
124
|
+
render_template("tasks/example_viewer.html.erb", "tasks/example_viewer.html.erb")
|
|
125
|
+
|
|
126
|
+
# Create exe/.gitkeep
|
|
127
|
+
exe_dir = File.join(@output_dir, "exe")
|
|
128
|
+
FileUtils.mkdir_p(exe_dir)
|
|
129
|
+
gitkeep = File.join(exe_dir, ".gitkeep")
|
|
130
|
+
FileUtils.touch(gitkeep)
|
|
131
|
+
puts " Created exe/.gitkeep"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private def copy_doc_files
|
|
135
|
+
doc_dir = File.join(@output_dir, "doc")
|
|
136
|
+
FileUtils.mkdir_p(doc_dir)
|
|
137
|
+
FileUtils.mkdir_p(File.join(doc_dir, "getting_started"))
|
|
138
|
+
FileUtils.mkdir_p(File.join(doc_dir, "concepts"))
|
|
139
|
+
FileUtils.mkdir_p(File.join(doc_dir, "images"))
|
|
140
|
+
|
|
141
|
+
# Render custom.css (now an ERB template)
|
|
142
|
+
render_template("doc/custom.css.erb", "doc/custom.css")
|
|
143
|
+
|
|
144
|
+
# Create images/.gitkeep
|
|
145
|
+
FileUtils.touch(File.join(doc_dir, "images", ".gitkeep"))
|
|
146
|
+
puts " Created doc/images/.gitkeep"
|
|
147
|
+
|
|
148
|
+
# Render doc templates
|
|
149
|
+
render_template("doc/index.md.erb", "doc/index.md")
|
|
150
|
+
render_template("doc/getting_started/quickstart.md.erb", "doc/getting_started/quickstart.md")
|
|
151
|
+
render_template("doc/concepts/application_architecture.md.erb", "doc/concepts/application_architecture.md")
|
|
152
|
+
render_template("doc/concepts/application_testing.md.erb", "doc/concepts/application_testing.md")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private def create_lib_structure
|
|
156
|
+
lib_path = File.join(@output_dir, "lib", @config.require_path)
|
|
157
|
+
FileUtils.mkdir_p(lib_path)
|
|
158
|
+
|
|
159
|
+
# RuboCop config shipped inside the gem (resolved via inherit_gem)
|
|
160
|
+
render_template("lib/rubocop.yml", "lib/#{@config.require_path}/rubocop.yml")
|
|
161
|
+
|
|
162
|
+
# Main require file
|
|
163
|
+
render_template("lib/main.rb.erb", "lib/#{@config.require_path}.rb")
|
|
164
|
+
|
|
165
|
+
# Version file
|
|
166
|
+
render_template("lib/version.rb.erb", "lib/#{@config.require_path}/version.rb")
|
|
167
|
+
|
|
168
|
+
# Test helper shipped inside the gem
|
|
169
|
+
render_template("lib/test_helper.rb.erb", "lib/#{@config.require_path}/test_helper.rb")
|
|
170
|
+
|
|
171
|
+
# Test directory with placeholder
|
|
172
|
+
FileUtils.mkdir_p(File.join(@output_dir, "test"))
|
|
173
|
+
render_template("test/test_helper.rb.erb", "test/test_helper.rb")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private def create_rust_extension
|
|
177
|
+
ext_name = @config.gem_name.tr("-", "_")
|
|
178
|
+
|
|
179
|
+
render_template("ext/Cargo.toml.erb", "ext/#{ext_name}/Cargo.toml")
|
|
180
|
+
render_template("ext/extconf.rb.erb", "ext/#{ext_name}/extconf.rb")
|
|
181
|
+
render_template("ext/.cargo/config.toml.erb", "ext/#{ext_name}/.cargo/config.toml")
|
|
182
|
+
render_template("ext/.gitignore.erb", "ext/#{ext_name}/.gitignore")
|
|
183
|
+
render_template("ext/clippy.toml.erb", "ext/#{ext_name}/clippy.toml")
|
|
184
|
+
render_template("ext/src/lib.rs.erb", "ext/#{ext_name}/src/lib.rs")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private def generate_nested_module(parts, content, indent = 0, is_outermost = true)
|
|
188
|
+
return content if parts.empty?
|
|
189
|
+
|
|
190
|
+
prefix = " " * indent
|
|
191
|
+
first, *rest = parts
|
|
192
|
+
inner = generate_nested_module(rest, content, indent + 1, false)
|
|
193
|
+
inner_prefix = " " * (indent + 1)
|
|
194
|
+
|
|
195
|
+
if rest.empty?
|
|
196
|
+
# Innermost module - content is a doc comment that goes BEFORE the module
|
|
197
|
+
if content.empty?
|
|
198
|
+
"#{prefix}module #{first}\n#{prefix}end"
|
|
199
|
+
else
|
|
200
|
+
"#{prefix}#{content}\n#{prefix}module #{first}\n#{prefix}end"
|
|
201
|
+
end
|
|
202
|
+
else
|
|
203
|
+
# Outer modules get :nodoc: to avoid "missing documentation" warnings
|
|
204
|
+
# (they're documented in the main require file or the core gem)
|
|
205
|
+
nodoc = is_outermost ? " # :nodoc: Documented in the ratatui_ruby gem." : ""
|
|
206
|
+
"#{prefix}module #{first}#{nodoc}\n#{inner}\n#{prefix}end"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
private def generate_nested_module_with_error(parts, indent = 0)
|
|
211
|
+
prefix = " " * indent
|
|
212
|
+
if parts.length == 1
|
|
213
|
+
# Innermost module with documented Error class
|
|
214
|
+
<<~RUBY.chomp
|
|
215
|
+
#{prefix}module #{parts.first}
|
|
216
|
+
#{prefix} # Base error class for this library.
|
|
217
|
+
#{prefix} #
|
|
218
|
+
#{prefix} # All library-specific exceptions inherit from this class.
|
|
219
|
+
#{prefix} # Catch this to handle any library error generically.
|
|
220
|
+
#{prefix} class Error < StandardError; end
|
|
221
|
+
#{prefix}end
|
|
222
|
+
RUBY
|
|
223
|
+
else
|
|
224
|
+
first, *rest = parts
|
|
225
|
+
inner = generate_nested_module_with_error(rest, indent + 1)
|
|
226
|
+
"#{prefix}module #{first}\n#{inner}\n#{prefix}end"
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
private def generate_nested_module_with_version(parts, indent = 0, is_outermost = true)
|
|
231
|
+
prefix = " " * indent
|
|
232
|
+
if parts.length == 1
|
|
233
|
+
# Innermost module with documented VERSION constant
|
|
234
|
+
<<~RUBY.chomp
|
|
235
|
+
#{prefix}module #{parts.first}
|
|
236
|
+
#{prefix} # The version of this gem.
|
|
237
|
+
#{prefix} # See https://semver.org/spec/v2.0.0.html
|
|
238
|
+
#{prefix} VERSION = "0.0.0"
|
|
239
|
+
#{prefix}end
|
|
240
|
+
RUBY
|
|
241
|
+
else
|
|
242
|
+
first, *rest = parts
|
|
243
|
+
inner = generate_nested_module_with_version(rest, indent + 1, false)
|
|
244
|
+
# Outer modules get :nodoc: to avoid "missing documentation" warnings
|
|
245
|
+
# (they're documented in the main require file or the core gem)
|
|
246
|
+
nodoc = is_outermost ? " # :nodoc: Documented in the ratatui_ruby gem." : ""
|
|
247
|
+
"#{prefix}module #{first}#{nodoc}\n#{inner}\n#{prefix}end"
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
private def binding_for_config
|
|
252
|
+
module_parts = @config.module_name.split("::")
|
|
253
|
+
ext_name = @config.gem_name.tr("-", "_")
|
|
254
|
+
|
|
255
|
+
# Create a binding with all config values available to templates
|
|
256
|
+
b = binding
|
|
257
|
+
b.local_variable_set(:gem_name, @config.gem_name)
|
|
258
|
+
b.local_variable_set(:gem_display_name, @config.module_name)
|
|
259
|
+
b.local_variable_set(:module_name, @config.module_name)
|
|
260
|
+
b.local_variable_set(:require_path, @config.require_path)
|
|
261
|
+
b.local_variable_set(:copyright_holder, @config.copyright_holder)
|
|
262
|
+
b.local_variable_set(:license, @config.license)
|
|
263
|
+
b.local_variable_set(:author, @config.author)
|
|
264
|
+
b.local_variable_set(:email, @config.email)
|
|
265
|
+
b.local_variable_set(:year, @config.year)
|
|
266
|
+
b.local_variable_set(:has_rdoc, true)
|
|
267
|
+
b.local_variable_set(:has_rust, @config.has_rust)
|
|
268
|
+
b.local_variable_set(:rubies, @config.ruby_versions)
|
|
269
|
+
b.local_variable_set(:ext_name, ext_name)
|
|
270
|
+
b.local_variable_set(:nested_module, generate_nested_module(module_parts, "# Namespace for library functionality."))
|
|
271
|
+
b.local_variable_set(:nested_module_with_version, generate_nested_module_with_version(module_parts))
|
|
272
|
+
b.local_variable_set(:update_mode, @update_mode)
|
|
273
|
+
b.local_variable_set(:dependencies, [])
|
|
274
|
+
b.local_variable_set(:summary, "Part of the RatatuiRuby ecosystem")
|
|
275
|
+
b.local_variable_set(:description, "#{@config.module_name} - part of the RatatuiRuby TUI framework ecosystem")
|
|
276
|
+
b.local_variable_set(:github_repo, "setdef/#{@config.module_name}")
|
|
277
|
+
b.local_variable_set(:project_name, @config.module_name)
|
|
278
|
+
b.local_variable_set(:version_file_require, "lib/#{@config.require_path}/version")
|
|
279
|
+
b.local_variable_set(:version_file, "lib/#{@config.require_path}/version.rb")
|
|
280
|
+
b
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
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 "rake"
|
|
9
|
+
|
|
10
|
+
module RatatuiRuby
|
|
11
|
+
module Devtools
|
|
12
|
+
# Where devtools' task files run. Imported at the top level, the helpers
|
|
13
|
+
# and constants they define would land on every object in your Rakefile.
|
|
14
|
+
# Here they stay with the tasks that use them, and the tasks can reach
|
|
15
|
+
# whatever the scope offers.
|
|
16
|
+
class TaskScope # :nodoc:
|
|
17
|
+
include Rake::DSL
|
|
18
|
+
|
|
19
|
+
def load(dir)
|
|
20
|
+
Dir.glob("#{dir}/*.rake").each { |path| instance_eval(File.read(path), path) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
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 "rake/tasklib"
|
|
9
|
+
require_relative "autodoc/examples"
|
|
10
|
+
|
|
11
|
+
class AutodocTasks < Rake::TaskLib # :nodoc:
|
|
12
|
+
def initialize
|
|
13
|
+
super
|
|
14
|
+
define
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private def define
|
|
18
|
+
namespace :autodoc do
|
|
19
|
+
desc "Update all automatically generated documentation"
|
|
20
|
+
task all: [:examples]
|
|
21
|
+
|
|
22
|
+
desc "Sync code snippets in example READMEs with source files"
|
|
23
|
+
task :examples do
|
|
24
|
+
Autodoc::Examples.sync
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
desc "Update all automatically generated documentation"
|
|
29
|
+
task autodoc: "autodoc:all"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
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 "repository"
|
|
9
|
+
require_relative "changelog"
|
|
10
|
+
|
|
11
|
+
# Base class for version bump workflows.
|
|
12
|
+
# Subclasses implement the template methods: assert_can_bump!, prepare,
|
|
13
|
+
# release_on_branch, finalize.
|
|
14
|
+
class BumpWorkflow # :nodoc:
|
|
15
|
+
def self.for(gem, segment, label = nil)
|
|
16
|
+
to(gem, gem.version.next(segment, label), segment)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# From 1.0 on, each minor version gets a release branch, so its users can
|
|
20
|
+
# take patch releases without the next minor's changes. The version a bump
|
|
21
|
+
# releases decides which branch it belongs on, whichever task named it: a
|
|
22
|
+
# patch bump that finishes 1.0.0-beta.1 opens the 1.0 series, and so does
|
|
23
|
+
# an exact bump to 1.0.0.
|
|
24
|
+
def self.to(gem, target, task)
|
|
25
|
+
workflow = if !target.stable?
|
|
26
|
+
UnbranchedRelease
|
|
27
|
+
elsif target.patch.zero?
|
|
28
|
+
ReleaseFromTrunk
|
|
29
|
+
else
|
|
30
|
+
PatchRelease
|
|
31
|
+
end
|
|
32
|
+
workflow.new(gem:, task:, target:)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(gem:, task:, target:, repository: Repository.new)
|
|
36
|
+
@gem = gem
|
|
37
|
+
@task = task
|
|
38
|
+
@target = target
|
|
39
|
+
@repository = repository
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def call
|
|
43
|
+
@repository.assert_pristine!
|
|
44
|
+
assert_can_bump!
|
|
45
|
+
|
|
46
|
+
prepare
|
|
47
|
+
release_on_branch
|
|
48
|
+
finalize
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
attr_reader :target
|
|
52
|
+
|
|
53
|
+
private def release_on_branch
|
|
54
|
+
changelog = Changelog.new
|
|
55
|
+
@commit_message = changelog.commit_message(target)
|
|
56
|
+
changelog.release(target)
|
|
57
|
+
@gem.update_version(target)
|
|
58
|
+
@repository.commit_all(@commit_message)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Every release but a patch to an older series happens on trunk.
|
|
62
|
+
private def assert_can_bump!
|
|
63
|
+
return if @repository.on_trunk?
|
|
64
|
+
|
|
65
|
+
raise ArgumentError, "Cannot bump:#{@task} from #{@repository.current_branch}. Switch to trunk first."
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Template methods for subclasses
|
|
69
|
+
private def prepare = nil
|
|
70
|
+
private def finalize = nil
|
|
71
|
+
end
|
|
@@ -41,14 +41,62 @@ class Changelog
|
|
|
41
41
|
unreleased = UnreleasedSection.parse(content)
|
|
42
42
|
links = Links.from_markdown(content)
|
|
43
43
|
|
|
44
|
-
raise "Could not parse CHANGELOG.md" unless header && unreleased
|
|
44
|
+
raise "Could not parse CHANGELOG.md" unless header && unreleased
|
|
45
45
|
|
|
46
|
-
history = History.parse(content, header.length, unreleased.to_s.length, links
|
|
46
|
+
history = History.parse(content, header.length, unreleased.to_s.length, links)
|
|
47
47
|
|
|
48
48
|
links.release(new_version)
|
|
49
49
|
history.add(unreleased.as_version(new_version))
|
|
50
50
|
|
|
51
|
-
File.write(@path, "#{header}#{UnreleasedSection.fresh}\n\n#{history}
|
|
51
|
+
File.write(@path, "#{header}#{UnreleasedSection.fresh}\n\n#{history}#{links.footer}")
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Removes entries from [Unreleased] that were released in the given version.
|
|
56
|
+
# Used when creating a release branch from trunk.
|
|
57
|
+
def prune_released_entries(released_entries)
|
|
58
|
+
content = File.read(@path)
|
|
59
|
+
|
|
60
|
+
header = Header.parse(content)
|
|
61
|
+
unreleased = UnreleasedSection.parse(content)
|
|
62
|
+
links = Links.from_markdown(content)
|
|
63
|
+
|
|
64
|
+
raise "Could not parse CHANGELOG.md" unless header && unreleased
|
|
65
|
+
|
|
66
|
+
history = History.parse(content, header.length, unreleased.to_s.length, links)
|
|
67
|
+
|
|
68
|
+
pruned = unreleased.without_entries(released_entries)
|
|
69
|
+
|
|
70
|
+
File.write(@path, "#{header}#{pruned}\n\n#{history}#{links.footer}")
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Imports a release section from another branch's changelog.
|
|
75
|
+
# Adds the version section to history and dedupes from [Unreleased].
|
|
76
|
+
# Uses "first wins" — if entry already deduped, doesn't re-add it.
|
|
77
|
+
def import_release(version, release_changelog_content)
|
|
78
|
+
release_section = extract_version_section(release_changelog_content, version)
|
|
79
|
+
return unless release_section
|
|
80
|
+
|
|
81
|
+
content = File.read(@path)
|
|
82
|
+
|
|
83
|
+
header = Header.parse(content)
|
|
84
|
+
unreleased = UnreleasedSection.parse(content)
|
|
85
|
+
links = Links.from_markdown(content)
|
|
86
|
+
|
|
87
|
+
raise "Could not parse CHANGELOG.md" unless header && unreleased
|
|
88
|
+
|
|
89
|
+
history = History.parse(content, header.length, unreleased.to_s.length, links)
|
|
90
|
+
|
|
91
|
+
# Add the release section to history (inserted in version order)
|
|
92
|
+
history.add(release_section)
|
|
93
|
+
links.release(version)
|
|
94
|
+
|
|
95
|
+
# Dedupe from [Unreleased] (first-wins: if already gone, no-op)
|
|
96
|
+
release_entries = release_section.lines.select { |l| l.strip.start_with?("- ") }.map(&:strip)
|
|
97
|
+
pruned = unreleased.without_entries(release_entries)
|
|
98
|
+
|
|
99
|
+
File.write(@path, "#{header}#{pruned}\n\n#{history}#{links.footer}")
|
|
52
100
|
nil
|
|
53
101
|
end
|
|
54
102
|
|
|
@@ -64,4 +112,13 @@ class Changelog
|
|
|
64
112
|
|
|
65
113
|
"chore: release v#{version}\n\n#{unreleased.commit_body}"
|
|
66
114
|
end
|
|
115
|
+
|
|
116
|
+
private def extract_version_section(changelog_content, version)
|
|
117
|
+
# Match the version heading and capture until the next version heading or links section
|
|
118
|
+
pattern = /^## \[#{Regexp.escape(version.to_s)}\][^\n]*\n(.*?)(?=^## \[|\n\[Unreleased\]:)/m
|
|
119
|
+
match = changelog_content.match(pattern)
|
|
120
|
+
return nil unless match
|
|
121
|
+
|
|
122
|
+
"## [#{version}]#{match[0].split("\n", 2).first.split(']', 2).last}\n#{match[1].rstrip}"
|
|
123
|
+
end
|
|
67
124
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
# A project on GitHub, found from any link into it, such as
|
|
9
|
+
# https://github.com/me/gem/releases/tag/HEAD.
|
|
10
|
+
class GitHubProject < Data.define(:url) # :nodoc:
|
|
11
|
+
LINK = %r{\A(https://github\.com/[^/]+/[^/]+)/}
|
|
12
|
+
|
|
13
|
+
def self.linked_from(link)
|
|
14
|
+
url = link[LINK, 1]
|
|
15
|
+
new(url:) if url
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Readers browse the code as it stands, rather than read a comparison.
|
|
19
|
+
def unreleased(_version)
|
|
20
|
+
"#{url}/tree/HEAD"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def release(version)
|
|
24
|
+
"#{url}/releases/tag/v#{version}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -21,10 +21,10 @@ class History
|
|
|
21
21
|
# [content] The full changelog text.
|
|
22
22
|
# [header_length] Length of the header section.
|
|
23
23
|
# [unreleased_length] Length of the Unreleased section.
|
|
24
|
-
# [
|
|
25
|
-
def self.parse(content, header_length, unreleased_length,
|
|
24
|
+
# [links] The changelog's Links, where the history ends.
|
|
25
|
+
def self.parse(content, header_length, unreleased_length, links)
|
|
26
26
|
start = header_length + unreleased_length
|
|
27
|
-
text = "#{content[start...(
|
|
27
|
+
text = "#{content[start...(links.start_in(content))].strip}\n"
|
|
28
28
|
new(text)
|
|
29
29
|
end
|
|
30
30
|
|
|
@@ -5,15 +5,19 @@
|
|
|
5
5
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
6
6
|
#++
|
|
7
7
|
|
|
8
|
+
require_relative "github_project"
|
|
9
|
+
require_relative "no_links"
|
|
10
|
+
require_relative "source_hut_project"
|
|
11
|
+
|
|
8
12
|
# Manages the version comparison links at the botton of the changelog.
|
|
9
13
|
#
|
|
10
|
-
# Release automation needs to update links. Manually calculating
|
|
11
|
-
# for every release is tedious and error-prone
|
|
12
|
-
#
|
|
14
|
+
# Release automation needs to update links. Manually calculating the URLs
|
|
15
|
+
# for every release is tedious and error-prone, and each forge shapes them
|
|
16
|
+
# differently.
|
|
13
17
|
#
|
|
14
18
|
# This class manages the collection of links. It parses them from the markdown.
|
|
15
|
-
# It
|
|
16
|
-
# "Unreleased" pointer.
|
|
19
|
+
# It finds the project from the "Unreleased" link and asks it for the new
|
|
20
|
+
# links, on GitHub or SourceHut. It properly shifts the "Unreleased" pointer.
|
|
17
21
|
#
|
|
18
22
|
# Use it to update the changelog during a release.
|
|
19
23
|
class Links
|
|
@@ -23,20 +27,20 @@ class Links
|
|
|
23
27
|
# wasteful. This pattern finds where the links begin.
|
|
24
28
|
PATTERN = /^(\[Unreleased\]: .*)$/m
|
|
25
29
|
|
|
26
|
-
# Regex to extract the
|
|
30
|
+
# Regex to extract the URL of the Unreleased link.
|
|
27
31
|
#
|
|
28
|
-
# New version links derive from the
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
# New version links derive from the project it links into.
|
|
33
|
+
UNRELEASED_PATTERN = /^\[Unreleased\]: (\S+)$/
|
|
34
|
+
|
|
35
|
+
# The forges whose links a release can write.
|
|
36
|
+
PROJECTS = [GitHubProject, SourceHutProject].freeze
|
|
31
37
|
|
|
32
38
|
# Creates a Links object from the full markdown content.
|
|
33
39
|
#
|
|
34
40
|
# [content] String. The full text of the changelog.
|
|
35
41
|
def self.from_markdown(content)
|
|
36
42
|
match = content.match(PATTERN)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
new(match[1].strip)
|
|
43
|
+
match ? new(match[1].strip) : NoLinks.new
|
|
40
44
|
end
|
|
41
45
|
|
|
42
46
|
# Returns the raw text of the links.
|
|
@@ -52,27 +56,38 @@ class Links
|
|
|
52
56
|
# Releases a new version.
|
|
53
57
|
#
|
|
54
58
|
# 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.
|
|
59
|
+
# the just-released version pointing to its specific tag. Warns instead when
|
|
60
|
+
# the "Unreleased" link isn't into a GitHub or SourceHut project.
|
|
56
61
|
#
|
|
57
62
|
# [version] String. The new version number (e.g., <tt>"0.5.0"</tt>).
|
|
58
63
|
def release(version)
|
|
59
|
-
|
|
64
|
+
link = @text[UNRELEASED_PATTERN, 1]
|
|
65
|
+
project = PROJECTS.lazy.filter_map { |forge| forge.linked_from(link) }.first
|
|
66
|
+
unless project
|
|
67
|
+
warn "The changelog's Unreleased link, #{link}, isn't into a GitHub or SourceHut project, " \
|
|
68
|
+
"so #{version} has no link. Add one by hand."
|
|
69
|
+
return self
|
|
70
|
+
end
|
|
60
71
|
|
|
61
|
-
|
|
62
|
-
new_version_link = "[#{version}]: #{base_url}v#{version}" # .../v1.0.0
|
|
63
|
-
|
|
64
|
-
@text.sub!(UNRELEASED_PATTERN, "#{new_unreleased}\n#{new_version_link}")
|
|
72
|
+
@text.sub!(UNRELEASED_PATTERN, "[Unreleased]: #{project.unreleased(version)}\n[#{version}]: #{project.release(version)}")
|
|
65
73
|
self
|
|
66
74
|
end
|
|
67
75
|
|
|
76
|
+
# Returns where the links begin in +content+, which is where the history
|
|
77
|
+
# before them ends.
|
|
78
|
+
#
|
|
79
|
+
# [content] String. The full text of the changelog.
|
|
80
|
+
def start_in(content)
|
|
81
|
+
content.index(@text)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Returns the links as the changelog ends with them, after a blank line.
|
|
85
|
+
def footer
|
|
86
|
+
"\n#{@text}\n"
|
|
87
|
+
end
|
|
88
|
+
|
|
68
89
|
# Returns the string representation of the links.
|
|
69
90
|
def to_s
|
|
70
91
|
@text
|
|
71
92
|
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
93
|
end
|