ratatui_ruby-devtools 0.2.1 → 0.4.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.
Files changed (134) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -1
  3. data/AGENTS.md +1 -1
  4. data/CHANGELOG.md +69 -7
  5. data/README.md +62 -21
  6. data/Rakefile +1 -1
  7. data/bin/setup +20 -6
  8. data/bin/setup.ps1 +74 -0
  9. data/doc/contributors/documentation_style.md +3 -4
  10. data/exe/scaffold +8 -342
  11. data/lib/ratatui_ruby/devtools/scaffold/scaffold_config.rb +67 -0
  12. data/lib/ratatui_ruby/devtools/scaffold/template_renderer.rb +282 -0
  13. data/lib/ratatui_ruby/devtools/task_scope.rb +24 -0
  14. data/lib/ratatui_ruby/devtools/tasks/autodoc_tasks.rb +31 -0
  15. data/lib/ratatui_ruby/devtools/tasks/bump/bump_workflow.rb +71 -0
  16. data/lib/ratatui_ruby/devtools/tasks/bump/changelog.rb +60 -3
  17. data/lib/ratatui_ruby/devtools/tasks/bump/github_project.rb +26 -0
  18. data/lib/ratatui_ruby/devtools/tasks/bump/history.rb +3 -3
  19. data/lib/ratatui_ruby/devtools/tasks/bump/links.rb +39 -24
  20. data/lib/ratatui_ruby/devtools/tasks/bump/manifest.rb +9 -17
  21. data/lib/ratatui_ruby/devtools/tasks/bump/no_crate.rb +19 -0
  22. data/lib/ratatui_ruby/devtools/tasks/bump/no_links.rb +22 -0
  23. data/lib/ratatui_ruby/devtools/tasks/bump/patch_release.rb +25 -0
  24. data/lib/ratatui_ruby/devtools/tasks/bump/release_branch.rb +17 -0
  25. data/lib/ratatui_ruby/devtools/tasks/bump/release_from_trunk.rb +48 -0
  26. data/lib/ratatui_ruby/devtools/tasks/bump/repository.rb +143 -0
  27. data/lib/ratatui_ruby/devtools/tasks/bump/ruby_gem.rb +65 -59
  28. data/lib/ratatui_ruby/devtools/tasks/bump/rust_crate.rb +42 -0
  29. data/lib/ratatui_ruby/devtools/tasks/bump/sem_ver.rb +127 -10
  30. data/lib/ratatui_ruby/devtools/tasks/bump/source_hut_project.rb +26 -0
  31. data/lib/ratatui_ruby/devtools/tasks/bump/unbranched_release.rb +20 -0
  32. data/lib/ratatui_ruby/devtools/tasks/bump/unreleased_section.rb +19 -1
  33. data/lib/ratatui_ruby/devtools/tasks/bump_tasks.rb +47 -0
  34. data/lib/ratatui_ruby/devtools/tasks/cargo_tasks.rb +57 -0
  35. data/lib/ratatui_ruby/devtools/tasks/doc/documentation.rb +59 -0
  36. data/lib/ratatui_ruby/devtools/tasks/doc/link/file_url.rb +30 -0
  37. data/lib/ratatui_ruby/devtools/tasks/doc/link/relative_path.rb +61 -0
  38. data/lib/ratatui_ruby/devtools/tasks/doc/link/web_url.rb +55 -0
  39. data/lib/ratatui_ruby/devtools/tasks/doc/link.rb +52 -0
  40. data/lib/ratatui_ruby/devtools/tasks/doc/link_audit.rb +116 -0
  41. data/lib/ratatui_ruby/devtools/tasks/doc/problem.rb +40 -0
  42. data/lib/ratatui_ruby/devtools/tasks/doc/source_file.rb +93 -0
  43. data/lib/ratatui_ruby/devtools/tasks/doc_tasks.rb +937 -0
  44. data/lib/ratatui_ruby/devtools/tasks/license/blame.rb +78 -0
  45. data/lib/ratatui_ruby/devtools/tasks/license/changed_files.rb +34 -0
  46. data/lib/ratatui_ruby/devtools/tasks/license/contribution.rb +12 -0
  47. data/lib/ratatui_ruby/devtools/tasks/license/copyright.rb +47 -0
  48. data/lib/ratatui_ruby/devtools/tasks/license/directory_licensing.rb +42 -0
  49. data/lib/ratatui_ruby/devtools/tasks/license/existing_header.rb +21 -0
  50. data/lib/ratatui_ruby/devtools/tasks/license/header_revision.rb +45 -0
  51. data/lib/ratatui_ruby/devtools/tasks/license/licensed_file.rb +39 -0
  52. data/lib/ratatui_ruby/devtools/tasks/license/licensing.rb +35 -0
  53. data/lib/ratatui_ruby/devtools/tasks/license/markdown_document.rb +23 -0
  54. data/lib/ratatui_ruby/devtools/tasks/license/markdown_files.rb +31 -0
  55. data/lib/ratatui_ruby/devtools/tasks/license/markdown_header_syntax.rb +104 -0
  56. data/lib/ratatui_ruby/devtools/tasks/license/markdown_snippet_syntax.rb +159 -0
  57. data/lib/ratatui_ruby/devtools/tasks/license/missing_header.rb +18 -0
  58. data/lib/ratatui_ruby/devtools/tasks/license/rbs_files.rb +27 -0
  59. data/lib/ratatui_ruby/devtools/tasks/license/rbs_header_syntax.rb +33 -0
  60. data/lib/ratatui_ruby/devtools/tasks/license/rdoc_comment.rb +51 -0
  61. data/lib/ratatui_ruby/devtools/tasks/license/rdoc_examples.rb +41 -0
  62. data/lib/ratatui_ruby/devtools/tasks/license/rdoc_snippet_syntax.rb +119 -0
  63. data/lib/ratatui_ruby/devtools/tasks/license/renewal.rb +17 -0
  64. data/lib/ratatui_ruby/devtools/tasks/license/reuse_annotated_file.rb +12 -0
  65. data/lib/ratatui_ruby/devtools/tasks/license/reuse_tool.rb +34 -0
  66. data/lib/ratatui_ruby/devtools/tasks/license/ruby_comment_header.rb +57 -0
  67. data/lib/ratatui_ruby/devtools/tasks/license/ruby_files.rb +36 -0
  68. data/lib/ratatui_ruby/devtools/tasks/license/ruby_header_syntax.rb +47 -0
  69. data/lib/ratatui_ruby/devtools/tasks/license/rust_files.rb +27 -0
  70. data/lib/ratatui_ruby/devtools/tasks/license/rust_header_syntax.rb +68 -0
  71. data/lib/ratatui_ruby/devtools/tasks/license/snippet.rb +44 -0
  72. data/lib/ratatui_ruby/devtools/tasks/license/snippet_file.rb +29 -0
  73. data/lib/ratatui_ruby/devtools/tasks/license/snippet_opening.rb +39 -0
  74. data/lib/ratatui_ruby/devtools/tasks/license/snippet_revision.rb +75 -0
  75. data/lib/ratatui_ruby/devtools/tasks/license/spdx_header.rb +42 -0
  76. data/lib/ratatui_ruby/devtools/tasks/license/unknown_file.rb +12 -0
  77. data/lib/ratatui_ruby/devtools/tasks/license/unlicensed_files.rb +69 -0
  78. data/lib/ratatui_ruby/devtools/tasks/license/wrapped_snippet.rb +30 -0
  79. data/lib/ratatui_ruby/devtools/tasks/license_tasks.rb +71 -0
  80. data/lib/ratatui_ruby/devtools/tasks/lint/gem_lockfile.rb +29 -0
  81. data/lib/ratatui_ruby/devtools/tasks/lint/supported_rubies.rb +18 -0
  82. data/lib/ratatui_ruby/devtools/tasks/lint_tasks.rb +100 -0
  83. data/lib/ratatui_ruby/devtools/tasks/release/platform_gem.rb +21 -12
  84. data/lib/ratatui_ruby/devtools/tasks/release/versioned_binary.rb +1 -1
  85. data/lib/ratatui_ruby/devtools/tasks/release_tasks.rb +80 -0
  86. data/lib/ratatui_ruby/devtools/tasks/reuse_tasks.rb +39 -0
  87. data/lib/ratatui_ruby/devtools/tasks/{test.rake → test_tasks.rb} +13 -4
  88. data/lib/ratatui_ruby/devtools/tasks/website/hosted_source_tree.rb +24 -0
  89. data/lib/ratatui_ruby/devtools/tasks/website/index_page.rb +1 -4
  90. data/lib/ratatui_ruby/devtools/tasks/website/source_tree.rb +63 -0
  91. data/lib/ratatui_ruby/devtools/tasks/website/versioned_documentation.rb +14 -13
  92. data/lib/ratatui_ruby/devtools/tasks/website_tasks.rb +49 -0
  93. data/lib/ratatui_ruby/devtools/templates/.github/workflows/build-gems.yml.erb +15 -9
  94. data/lib/ratatui_ruby/devtools/templates/.github/workflows/ci.yml.erb +2 -2
  95. data/lib/ratatui_ruby/devtools/templates/.gitignore.erb +2 -1
  96. data/lib/ratatui_ruby/devtools/templates/.rubocop.yml.erb +1 -1
  97. data/lib/ratatui_ruby/devtools/templates/AGENTS.md.erb +1 -1
  98. data/lib/ratatui_ruby/devtools/templates/CHANGELOG.md.erb +3 -1
  99. data/lib/ratatui_ruby/devtools/templates/CONTRIBUTING.md.erb +3 -3
  100. data/lib/ratatui_ruby/devtools/templates/Gemfile.erb +2 -3
  101. data/lib/ratatui_ruby/devtools/templates/README.md.erb +7 -7
  102. data/lib/ratatui_ruby/devtools/templates/REUSE.toml.erb +1 -1
  103. data/lib/ratatui_ruby/devtools/templates/bin/setup.erb +5 -1
  104. data/lib/ratatui_ruby/devtools/templates/bin/setup.ps1.erb +5 -1
  105. data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_architecture.md.erb +2 -2
  106. data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_testing.md.erb +3 -3
  107. data/lib/ratatui_ruby/devtools/templates/doc/getting_started/quickstart.md.erb +4 -4
  108. data/lib/ratatui_ruby/devtools/templates/ext/Cargo.toml.erb +1 -1
  109. data/lib/ratatui_ruby/devtools/templates/gemspec.erb +1 -1
  110. data/lib/ratatui_ruby/devtools/templates/mise.toml.erb +3 -3
  111. data/lib/ratatui_ruby/devtools/version.rb +1 -1
  112. data/lib/ratatui_ruby/devtools.rb +54 -71
  113. data/mise.toml +2 -2
  114. data/sig/ratatui_ruby/devtools.rbs +16 -1
  115. metadata +93 -34
  116. data/.builds/ruby-4.0.yml +0 -38
  117. data/lib/ratatui_ruby/devtools/tasks/autodoc.rake +0 -21
  118. data/lib/ratatui_ruby/devtools/tasks/bump.rake +0 -80
  119. data/lib/ratatui_ruby/devtools/tasks/cargo.rake +0 -47
  120. data/lib/ratatui_ruby/devtools/tasks/doc.rake +0 -887
  121. data/lib/ratatui_ruby/devtools/tasks/license/headers_md.rb +0 -276
  122. data/lib/ratatui_ruby/devtools/tasks/license/headers_rb.rb +0 -236
  123. data/lib/ratatui_ruby/devtools/tasks/license/license_utils.rb +0 -143
  124. data/lib/ratatui_ruby/devtools/tasks/license/snippets_md.rb +0 -353
  125. data/lib/ratatui_ruby/devtools/tasks/license/snippets_rdoc.rb +0 -186
  126. data/lib/ratatui_ruby/devtools/tasks/license.rake +0 -91
  127. data/lib/ratatui_ruby/devtools/tasks/lint.rake +0 -80
  128. data/lib/ratatui_ruby/devtools/tasks/release.rake +0 -87
  129. data/lib/ratatui_ruby/devtools/tasks/resources/rubies.yml +0 -7
  130. data/lib/ratatui_ruby/devtools/tasks/reuse.rake +0 -104
  131. data/lib/ratatui_ruby/devtools/tasks/website.rake +0 -37
  132. data/lib/ratatui_ruby/devtools/templates/.builds/ruby.yml.erb +0 -47
  133. data/lib/ratatui_ruby/devtools/templates/tasks/resources/index.html.erb +0 -45
  134. 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 && links
44
+ raise "Could not parse CHANGELOG.md" unless header && unreleased
45
45
 
46
- history = History.parse(content, header.length, unreleased.to_s.length, links.to_s)
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}\n#{links}")
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
- # [links_text] The links section text (used as end marker).
25
- def self.parse(content, header_length, unreleased_length, links_text)
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...(content.index(links_text))].strip}\n"
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 git diff URLs
11
- # for every release is tedious and error-prone. SourceHut does not have
12
- # standard comparison views, complicating matters further.
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 generates the correct tree links for SourceHut. It properly shifts the
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 base URL from the Unreleased link.
30
+ # Regex to extract the URL of the Unreleased link.
27
31
  #
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$}
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
- return unless match
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
- return unless base_url
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
- 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}")
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