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.
Files changed (90) hide show
  1. checksums.yaml +7 -0
  2. data/.builds/ruby-4.0.yml +38 -0
  3. data/.pre-commit-config.yaml +16 -0
  4. data/.rubocop.yml +8 -0
  5. data/AGENTS.md +72 -0
  6. data/CHANGELOG.md +23 -0
  7. data/LICENSE +661 -0
  8. data/LICENSES/AGPL-3.0-or-later.txt +661 -0
  9. data/LICENSES/CC-BY-SA-4.0.txt +427 -0
  10. data/LICENSES/CC0-1.0.txt +121 -0
  11. data/LICENSES/MIT-0.txt +16 -0
  12. data/LICENSES/MIT.txt +18 -0
  13. data/README.md +199 -0
  14. data/REUSE.toml +18 -0
  15. data/Rakefile +13 -0
  16. data/bin/agent_rake +13 -0
  17. data/bin/announce +13 -0
  18. data/bin/console +14 -0
  19. data/bin/consolidate_md +13 -0
  20. data/bin/hbs +13 -0
  21. data/bin/setup +17 -0
  22. data/doc/contributors/documentation_style.md +121 -0
  23. data/doc/custom.css +22 -0
  24. data/exe/agent_rake +96 -0
  25. data/exe/announce +1120 -0
  26. data/exe/consolidate_md +246 -0
  27. data/exe/hbs +670 -0
  28. data/exe/scaffold +662 -0
  29. data/lib/ratatui_ruby/devtools/tasks/autodoc/examples.rb +133 -0
  30. data/lib/ratatui_ruby/devtools/tasks/autodoc/member.rb +116 -0
  31. data/lib/ratatui_ruby/devtools/tasks/autodoc/name.rb +33 -0
  32. data/lib/ratatui_ruby/devtools/tasks/autodoc.rake +21 -0
  33. data/lib/ratatui_ruby/devtools/tasks/bump/cargo_lockfile.rb +38 -0
  34. data/lib/ratatui_ruby/devtools/tasks/bump/changelog.rb +67 -0
  35. data/lib/ratatui_ruby/devtools/tasks/bump/header.rb +43 -0
  36. data/lib/ratatui_ruby/devtools/tasks/bump/history.rb +50 -0
  37. data/lib/ratatui_ruby/devtools/tasks/bump/links.rb +78 -0
  38. data/lib/ratatui_ruby/devtools/tasks/bump/manifest.rb +63 -0
  39. data/lib/ratatui_ruby/devtools/tasks/bump/ruby_gem.rb +77 -0
  40. data/lib/ratatui_ruby/devtools/tasks/bump/sem_ver.rb +63 -0
  41. data/lib/ratatui_ruby/devtools/tasks/bump/unreleased_section.rb +75 -0
  42. data/lib/ratatui_ruby/devtools/tasks/bump.rake +80 -0
  43. data/lib/ratatui_ruby/devtools/tasks/cargo.rake +47 -0
  44. data/lib/ratatui_ruby/devtools/tasks/doc.rake +887 -0
  45. data/lib/ratatui_ruby/devtools/tasks/example_viewer.html.erb +172 -0
  46. data/lib/ratatui_ruby/devtools/tasks/license/headers_md.rb +276 -0
  47. data/lib/ratatui_ruby/devtools/tasks/license/headers_rb.rb +236 -0
  48. data/lib/ratatui_ruby/devtools/tasks/license/license_utils.rb +143 -0
  49. data/lib/ratatui_ruby/devtools/tasks/license/snippets_md.rb +353 -0
  50. data/lib/ratatui_ruby/devtools/tasks/license/snippets_rdoc.rb +186 -0
  51. data/lib/ratatui_ruby/devtools/tasks/license.rake +91 -0
  52. data/lib/ratatui_ruby/devtools/tasks/lint.rake +84 -0
  53. data/lib/ratatui_ruby/devtools/tasks/rdoc_config.rb +45 -0
  54. data/lib/ratatui_ruby/devtools/tasks/resources/build.yml.erb +54 -0
  55. data/lib/ratatui_ruby/devtools/tasks/resources/rubies.yml +7 -0
  56. data/lib/ratatui_ruby/devtools/tasks/reuse.rake +104 -0
  57. data/lib/ratatui_ruby/devtools/tasks/sourcehut.rake +94 -0
  58. data/lib/ratatui_ruby/devtools/tasks/test.rake +18 -0
  59. data/lib/ratatui_ruby/devtools/templates/.builds/ruby.yml.erb +47 -0
  60. data/lib/ratatui_ruby/devtools/templates/.gitignore.erb +18 -0
  61. data/lib/ratatui_ruby/devtools/templates/.pre-commit-config.yaml.erb +16 -0
  62. data/lib/ratatui_ruby/devtools/templates/.rubocop.yml.erb +8 -0
  63. data/lib/ratatui_ruby/devtools/templates/AGENTS.md.erb +65 -0
  64. data/lib/ratatui_ruby/devtools/templates/CHANGELOG.md.erb +18 -0
  65. data/lib/ratatui_ruby/devtools/templates/Gemfile.erb +32 -0
  66. data/lib/ratatui_ruby/devtools/templates/README.md.erb +127 -0
  67. data/lib/ratatui_ruby/devtools/templates/REUSE.toml.erb +33 -0
  68. data/lib/ratatui_ruby/devtools/templates/Rakefile.erb +29 -0
  69. data/lib/ratatui_ruby/devtools/templates/bin/console.erb +18 -0
  70. data/lib/ratatui_ruby/devtools/templates/bin/setup.erb +24 -0
  71. data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_architecture.md.erb +16 -0
  72. data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_testing.md.erb +49 -0
  73. data/lib/ratatui_ruby/devtools/templates/doc/custom.css.erb +24 -0
  74. data/lib/ratatui_ruby/devtools/templates/doc/getting_started/quickstart.md.erb +56 -0
  75. data/lib/ratatui_ruby/devtools/templates/doc/images/.gitkeep +0 -0
  76. data/lib/ratatui_ruby/devtools/templates/doc/index.md.erb +25 -0
  77. data/lib/ratatui_ruby/devtools/templates/exe/.gitkeep +0 -0
  78. data/lib/ratatui_ruby/devtools/templates/gemspec.erb +58 -0
  79. data/lib/ratatui_ruby/devtools/templates/mise.toml.erb +12 -0
  80. data/lib/ratatui_ruby/devtools/templates/tasks/example_viewer.html.erb +174 -0
  81. data/lib/ratatui_ruby/devtools/templates/tasks/resources/build.yml.erb +62 -0
  82. data/lib/ratatui_ruby/devtools/templates/tasks/resources/index.html.erb +46 -0
  83. data/lib/ratatui_ruby/devtools/templates/tasks/resources/rubies.yml.erb +9 -0
  84. data/lib/ratatui_ruby/devtools/templates/vendor/goodcop/base.yml +1047 -0
  85. data/lib/ratatui_ruby/devtools/version.rb +13 -0
  86. data/lib/ratatui_ruby/devtools.rb +137 -0
  87. data/mise.toml +7 -0
  88. data/sig/ratatui_ruby/devtools.rbs +15 -0
  89. data/vendor/goodcop/base.yml +1047 -0
  90. metadata +252 -0
@@ -0,0 +1,172 @@
1
+ <%#
2
+ SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
3
+ SPDX-License-Identifier: AGPL-3.0-or-later
4
+ %>
5
+ <!DOCTYPE html>
6
+ <html lang="en">
7
+ <head>
8
+ <meta charset="UTF-8">
9
+ <meta name="viewport" content="width=device-width, initial-scale=1">
10
+ <title><%= page_title %> - Example Viewer</title>
11
+
12
+ <script>
13
+ var rdoc_rel_prefix = "<%= doc_root_link.sub('index.html', '') %>";
14
+ var index_rel_prefix = "<%= '../' * (relative_path.split('/').size - 1) %>";
15
+ </script>
16
+
17
+ <script src="<%= '../' * (relative_path.split('/').size - 1) %>js/search_navigation.js" defer></script>
18
+ <script src="<%= '../' * (relative_path.split('/').size - 1) %>js/search_data.js" defer></script>
19
+ <script src="<%= '../' * (relative_path.split('/').size - 1) %>js/search_ranker.js" defer></script>
20
+ <script src="<%= '../' * (relative_path.split('/').size - 1) %>js/search_controller.js" defer></script>
21
+ <script src="<%= '../' * (relative_path.split('/').size - 1) %>js/aliki.js" defer></script>
22
+
23
+ <link href="<%= doc_root_link.sub('index.html', '') %>css/rdoc.css" rel="stylesheet">
24
+ <link href="<%= doc_root_link.sub('index.html', '') %>custom.css" rel="stylesheet">
25
+ </head>
26
+ <body class="file<%= ' has-toc' unless toc_items.empty? %>">
27
+ <%= icons_svg %>
28
+ <header class="top-navbar">
29
+ <div class="navbar-brand">
30
+ Example Viewer
31
+ </div>
32
+
33
+ <!-- Desktop search bar -->
34
+ <div class="navbar-search navbar-search-desktop" role="search">
35
+ <form action="#" method="get" accept-charset="utf-8">
36
+ <input id="search-field" role="combobox" aria-label="Search"
37
+ aria-autocomplete="list" aria-controls="search-results-desktop"
38
+ type="text" name="search" placeholder="Search (/) examples..."
39
+ spellcheck="false" autocomplete="off"
40
+ title="Type to search, Up and Down to navigate, Enter to load">
41
+ <ul id="search-results-desktop" aria-label="Search Results"
42
+ aria-busy="false" aria-expanded="false"
43
+ aria-atomic="false" class="initially-hidden search-results"></ul>
44
+ </form>
45
+ </div>
46
+
47
+ <!-- Mobile search icon button -->
48
+ <button id="search-toggle" class="navbar-search-mobile" aria-label="Open search" type="button">
49
+ <span aria-hidden="true">🔍</span>
50
+ </button>
51
+
52
+ <button id="theme-toggle" class="theme-toggle" aria-label="Switch to dark mode" type="button" onclick="cycleColorMode()">
53
+ <span class="theme-toggle-icon" aria-hidden="true">🌙</span>
54
+ </button>
55
+ </header>
56
+
57
+ <!-- Search Modal (Mobile) -->
58
+ <div id="search-modal" class="search-modal" hidden aria-modal="true" role="dialog" aria-label="Search">
59
+ <div class="search-modal-backdrop"></div>
60
+ <div class="search-modal-content">
61
+ <div class="search-modal-header">
62
+ <form class="search-modal-form" action="#" method="get" accept-charset="utf-8">
63
+ <span class="search-modal-icon" aria-hidden="true">🔍</span>
64
+ <input id="search-field-mobile" role="combobox" aria-label="Search"
65
+ aria-autocomplete="list" aria-controls="search-results-mobile"
66
+ type="text" name="search" placeholder="Search examples"
67
+ spellcheck="false" autocomplete="off">
68
+ <button type="button" class="search-modal-close" aria-label="Close search" id="search-modal-close">
69
+ <span aria-hidden="true">esc</span>
70
+ </button>
71
+ </form>
72
+ </div>
73
+ <div class="search-modal-body">
74
+ <ul id="search-results-mobile" aria-label="Search Results"
75
+ aria-busy="false" aria-expanded="false"
76
+ aria-atomic="false" class="search-results search-modal-results initially-hidden"></ul>
77
+ <div class="search-modal-empty">
78
+ <p>No recent searches</p>
79
+ </div>
80
+ </div>
81
+ </div>
82
+ </div>
83
+
84
+ <nav id="navigation" role="navigation">
85
+ <div id="fileindex-section" class="nav-section">
86
+ <details class="nav-section-collapsible" open>
87
+ <summary class="nav-section-header">
88
+ <span class="nav-section-icon">
89
+ <svg><use href="#icon-file"></use></svg>
90
+ </span>
91
+ <span class="nav-section-title">Examples</span>
92
+ <span class="nav-section-chevron">
93
+ <svg><use href="#icon-chevron"></use></svg>
94
+ </span>
95
+ </summary>
96
+ <ul class="nav-list">
97
+ <li><a href="<%= doc_root_link %>">← Back to Docs</a></li>
98
+ </ul>
99
+ </details>
100
+ </div>
101
+ <div class="nav-section">
102
+ <details class="nav-section-collapsible" open>
103
+ <summary class="nav-section-header">
104
+ <span class="nav-section-icon">
105
+ <svg><use href="#icon-layers"></use></svg>
106
+ </span>
107
+ <span class="nav-section-title">Files</span>
108
+ <span class="nav-section-chevron">
109
+ <svg><use href="#icon-chevron"></use></svg>
110
+ </span>
111
+ </summary>
112
+ <ul class="link-list nav-list">
113
+ <%= render_tree(tree_data, relative_path, current_file_html) %>
114
+ </ul>
115
+ </details>
116
+ </div>
117
+ </nav>
118
+
119
+ <main role="main">
120
+ <div class="breadcrumb">
121
+ <%= breadcrumb_path %>
122
+ </div>
123
+ <%= file_header_html %>
124
+ <div class="content">
125
+ <%= file_content_html %>
126
+ </div>
127
+ </main>
128
+
129
+ <% unless toc_items.empty? %>
130
+ <aside class="table-of-contents" role="complementary" aria-label="Table of Contents">
131
+ <div class="toc-sticky">
132
+ <div class="toc-list">
133
+ <h3>On This Page</h3>
134
+ <%= render_toc(toc_items) %>
135
+ </div>
136
+ </div>
137
+ </aside>
138
+ <% end %>
139
+
140
+
141
+ <script>
142
+ const modes = ['auto', 'light', 'dark'];
143
+ const icons = { auto: '🌓', light: '☀️', dark: '🌙' };
144
+
145
+ function setColorMode(mode) {
146
+ if (mode === 'auto') {
147
+ document.documentElement.removeAttribute('data-theme');
148
+ const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
149
+ document.documentElement.setAttribute('data-theme', systemTheme);
150
+ } else {
151
+ document.documentElement.setAttribute('data-theme', mode);
152
+ }
153
+
154
+ const icon = icons[mode];
155
+ const toggle = document.getElementById('theme-toggle');
156
+ toggle.querySelector('.theme-toggle-icon').textContent = icon;
157
+
158
+ localStorage.setItem('rdoc-theme', mode);
159
+ }
160
+
161
+ function cycleColorMode() {
162
+ const current = localStorage.getItem('rdoc-theme') || 'auto';
163
+ const currentIndex = modes.indexOf(current);
164
+ const nextMode = modes[(currentIndex + 1) % modes.length];
165
+ setColorMode(nextMode);
166
+ }
167
+
168
+ const savedMode = localStorage.getItem('rdoc-theme') || 'auto';
169
+ setColorMode(savedMode);
170
+ </script>
171
+ </body>
172
+ </html>
@@ -0,0 +1,276 @@
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
+ # Ensures markdown files have correct SPDX headers.
9
+ #
10
+ # Open source projects need license headers in every file. REUSE compliance
11
+ # requires SPDX format. Adding and updating these headers by hand is tedious.
12
+ # Years drift. Contributors are forgotten.
13
+ #
14
+ # This script processes markdown files. It adds CC-BY-SA-4.0 headers where
15
+ # missing. It updates copyright years based on git history. It preserves
16
+ # existing contributors.
17
+ #
18
+ # Run it as part of license:headers:md rake task.
19
+ #
20
+ # === Example
21
+ #
22
+ # ruby tasks/license/headers_md.rb README.md
23
+ # ruby tasks/license/headers_md.rb doc/
24
+ # ruby tasks/license/headers_md.rb # all .md files
25
+ #
26
+ # Rules:
27
+ # - Ensures file has CC-BY-SA-4.0 license header with YOUR copyright
28
+ # - Updates years for EXISTING contributors based on git blame + Co-Authored-By
29
+ # - Does NOT add new contributors from git history - only updates existing ones
30
+ # - Uses non-code-block lines for year calculation
31
+ # - Adds header with YOUR copyright if missing
32
+
33
+ require_relative "license_utils"
34
+
35
+ # Your name for copyright headers.
36
+ YOUR_NAME = "Kerrick Long"
37
+
38
+ # Your email for copyright headers.
39
+ YOUR_EMAIL = "me@kerricklong.com"
40
+
41
+ # Identifiers used to match your contributions in git history.
42
+ YOUR_IDENTIFIERS = [YOUR_NAME, YOUR_EMAIL].freeze
43
+
44
+ # Full copyright string for headers.
45
+ YOUR_COPYRIGHT = "#{YOUR_NAME} <#{YOUR_EMAIL}>"
46
+
47
+ # The SPDX license identifier for markdown documentation.
48
+ LICENSE = "CC-BY-SA-4.0"
49
+
50
+ # Identifies fenced code blocks in markdown content.
51
+ #
52
+ # Copyright years come from git blame. Code blocks contain pasted content,
53
+ # not original prose. Blaming code block lines produces wrong contributors.
54
+ # Exclude these ranges when calculating copyright years.
55
+ #
56
+ # [lines] Array of line strings from the file.
57
+ def find_code_blocks(lines)
58
+ blocks = []
59
+ i = 0
60
+
61
+ while i < lines.length
62
+ line = lines[i]
63
+
64
+ if line =~ /^(````*)(\w*)$/
65
+ fence_marker = $1
66
+ fence_start = i
67
+ re_end = /^#{Regexp.escape(fence_marker)}$/
68
+
69
+ j = i + 1
70
+ while j < lines.length
71
+ if lines[j] =~ re_end
72
+ blocks << { start: fence_start, end: j }
73
+ i = j
74
+ break
75
+ end
76
+ j += 1
77
+ end
78
+ end
79
+
80
+ i += 1
81
+ end
82
+
83
+ blocks
84
+ end
85
+
86
+ # Calculates line ranges outside code blocks.
87
+ #
88
+ # Copyright years come from git blame. Blaming the entire file includes code
89
+ # blocks. This function returns only prose ranges for accurate year lookup.
90
+ #
91
+ # [lines] Array of line strings from the file.
92
+ def get_non_code_line_ranges(lines)
93
+ header_end = 0
94
+ if lines[0]&.include?("<!--")
95
+ (0...(lines.length)).each do |i|
96
+ if lines[i].include?("-->")
97
+ header_end = i + 1
98
+ break
99
+ end
100
+ end
101
+ end
102
+
103
+ code_blocks = find_code_blocks(lines)
104
+ non_code_ranges = []
105
+ current_line = header_end
106
+
107
+ code_blocks.each do |block|
108
+ if current_line < block[:start]
109
+ non_code_ranges << [current_line + 1, block[:start]]
110
+ end
111
+ current_line = block[:end] + 1
112
+ end
113
+
114
+ if current_line < lines.length
115
+ non_code_ranges << [current_line + 1, lines.length]
116
+ end
117
+
118
+ non_code_ranges
119
+ end
120
+
121
+ # Extracts existing SPDX header from markdown content.
122
+ #
123
+ # Files may already have headers. Updating requires parsing existing copyright
124
+ # holders and years. This extracts them for comparison and update.
125
+ #
126
+ # [lines] Array of line strings from the file.
127
+ def parse_existing_header(lines)
128
+ return nil unless lines[0]&.include?("<!--")
129
+
130
+ header_end = nil
131
+ copyrights = []
132
+ license = nil
133
+
134
+ (0...(lines.length)).each do |i|
135
+ line = lines[i]
136
+
137
+ if line =~ /SPDX-FileCopyrightText:\s*(\d{4})\s+(.+)$/
138
+ copyrights << { year: $1.to_i, holder: $2.strip }
139
+ # REUSE-IgnoreStart
140
+ elsif line =~ /SPDX-License-Identifier:\s*(.+)$/
141
+ # REUSE-IgnoreEnd
142
+ license = $1.strip
143
+ end
144
+
145
+ if line.include?("-->")
146
+ header_end = i
147
+ break
148
+ end
149
+ end
150
+
151
+ return nil if header_end.nil?
152
+ return nil if copyrights.empty? && license.nil?
153
+
154
+ { end_line: header_end, copyrights:, license: }
155
+ end
156
+
157
+ # Updates or adds SPDX headers for a single markdown file.
158
+ #
159
+ # Each file needs correct CC-BY-SA-4.0 headers with accurate copyright years.
160
+ # Processing involves reading, parsing, querying git, and rewriting. This
161
+ # function orchestrates that workflow.
162
+ #
163
+ # [filepath] Path to the markdown file.
164
+ def process_file(filepath)
165
+ content = File.read(filepath)
166
+ lines = content.lines
167
+
168
+ non_code_ranges = get_non_code_line_ranges(lines)
169
+
170
+ # Get contributors from non-code lines for year lookups
171
+ all_contributors = {}
172
+ non_code_ranges.each do |start_line, end_line|
173
+ range_contributors = LicenseUtils.get_contributors_for_lines(filepath, start_line, end_line)
174
+ range_contributors.each do |contributor, year|
175
+ all_contributors[contributor] = [all_contributors[contributor] || 0, year].max
176
+ end
177
+ end
178
+
179
+ your_year = nil
180
+ all_contributors.each do |contributor, year|
181
+ if YOUR_IDENTIFIERS.any? { |id| contributor.include?(id) }
182
+ your_year = [your_year || 0, year].max
183
+ end
184
+ end
185
+ your_year ||= Date.today.year
186
+
187
+ existing = parse_existing_header(lines)
188
+
189
+ if existing
190
+ # Only update years for EXISTING contributors
191
+ needs_update = false
192
+ updated_copyrights = []
193
+
194
+ existing[:copyrights].each do |c|
195
+ git_year = nil
196
+ all_contributors.each do |contributor, year|
197
+ if c[:holder].split.any? { |word| contributor.include?(word) }
198
+ git_year = [git_year || 0, year].max
199
+ end
200
+ end
201
+
202
+ if git_year && git_year != c[:year]
203
+ puts " Updated #{c[:holder].split.first}'s copyright year: #{c[:year]} -> #{git_year}"
204
+ updated_copyrights << { year: git_year, holder: c[:holder] }
205
+ needs_update = true
206
+ else
207
+ updated_copyrights << c
208
+ end
209
+ end
210
+
211
+ # Check if YOUR year needs updating
212
+ your_existing = updated_copyrights.find { |c| YOUR_IDENTIFIERS.any? { |id| c[:holder].include?(id) } }
213
+ if your_existing.nil?
214
+ puts " Adding your copyright"
215
+ updated_copyrights << { year: your_year, holder: YOUR_COPYRIGHT }
216
+ needs_update = true
217
+ end
218
+
219
+ if existing[:license] != LICENSE
220
+ puts " Fixing license: #{existing[:license]} -> #{LICENSE}"
221
+ needs_update = true
222
+ end
223
+
224
+ if needs_update
225
+ # REUSE-IgnoreStart
226
+ header_lines = ["<!--\n"]
227
+ updated_copyrights.each do |c|
228
+ header_lines << " SPDX-FileCopyrightText: #{c[:year]} #{c[:holder]}\n"
229
+ end
230
+ header_lines << " SPDX-License-Identifier: #{LICENSE}\n"
231
+ header_lines << "-->\n"
232
+ # REUSE-IgnoreEnd
233
+
234
+ remaining = lines[(existing[:end_line] + 1)..]
235
+ File.write(filepath, header_lines.join + remaining.join)
236
+ puts "Updated: #{filepath}"
237
+ end
238
+ else
239
+ # No header - add one with YOUR copyright only
240
+ # REUSE-IgnoreStart
241
+ header = "<!--\n SPDX-FileCopyrightText: #{your_year} #{YOUR_COPYRIGHT}\n SPDX-License-Identifier: #{LICENSE}\n-->\n"
242
+ # REUSE-IgnoreEnd
243
+
244
+ File.write(filepath, header + content)
245
+ puts "Added header: #{filepath}"
246
+ end
247
+ end
248
+
249
+ # Finds markdown files to process.
250
+ #
251
+ # License automation runs on file sets. Users may specify paths or want all
252
+ # files. This handles both cases using git ls-files for tracking.
253
+ #
254
+ # [paths] Explicit paths to process, or empty for all tracked .md files.
255
+ def find_md_files(paths)
256
+ if paths.empty?
257
+ `git ls-files '*.md'`.split("\n")
258
+ else
259
+ paths.flat_map do |path|
260
+ if File.directory?(path)
261
+ `git ls-files '#{path}/**/*.md'`.split("\n")
262
+ else
263
+ path
264
+ end
265
+ end
266
+ end
267
+ end
268
+
269
+ if __FILE__ == $0
270
+ paths = ARGV.empty? ? [] : ARGV
271
+ files = find_md_files(paths)
272
+
273
+ files.each do |file|
274
+ process_file(file)
275
+ end
276
+ end
@@ -0,0 +1,236 @@
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
+ # Script to ensure Ruby files have correct SPDX file headers.
9
+ #
10
+ # Usage: ruby tasks/license/headers_rb.rb [path...]
11
+ #
12
+ # If no paths are given, processes lib/, ext/, test/, examples/, tasks/, bin/.
13
+ #
14
+ # License selection by directory:
15
+ # - lib/, ext/, test/ → LGPL-3.0-or-later
16
+ # - examples/widget_*, examples/verify_* → MIT-0
17
+ # - examples/app_*, tasks/, bin/ → AGPL-3.0-or-later
18
+
19
+ require_relative "license_utils"
20
+
21
+ YOUR_NAME = "Kerrick Long"
22
+ YOUR_EMAIL = "me@kerricklong.com"
23
+ YOUR_IDENTIFIERS = [YOUR_NAME, YOUR_EMAIL].freeze
24
+ YOUR_COPYRIGHT = "#{YOUR_NAME} <#{YOUR_EMAIL}>"
25
+
26
+ # Selects the appropriate license based on file location.
27
+ #
28
+ # Different parts of the codebase have different licenses. Library code is
29
+ # LGPL. Examples are MIT-0 or AGPL. This function routes files to their
30
+ # correct license by path pattern.
31
+ #
32
+ # [filepath] Path to the Ruby file.
33
+ def license_for_file(filepath)
34
+ case filepath
35
+ when %r{^(lib|sig/ratatui_ruby|ext|test)/}
36
+ "LGPL-3.0-or-later"
37
+ when %r{^(examples|sig/examples)/(widget_|verify_)}
38
+ "MIT-0"
39
+ else
40
+ "AGPL-3.0-or-later"
41
+ end
42
+ end
43
+
44
+ # Extracts existing SPDX header from Ruby file content.
45
+ #
46
+ # Files may already have headers. Updating requires parsing existing copyright
47
+ # holders and years. This extracts them for comparison and update.
48
+ #
49
+ # [lines] Array of line strings from the file.
50
+ def parse_existing_header(lines)
51
+ # Returns { end_line:, copyrights: [{year:, holder:}], license: }
52
+ # REUSE-IgnoreStart
53
+ # Ruby files typically have:
54
+ # # frozen_string_literal: true
55
+ # (blank line)
56
+ # #--
57
+ # # SPDX-FileCopyrightText: YYYY Name
58
+ # # SPDX-License-Identifier: LICENSE
59
+ # #++
60
+ # REUSE-IgnoreEnd
61
+
62
+ copyrights = []
63
+ license = nil
64
+ header_end = nil
65
+ found_spdx = false
66
+
67
+ lines.each_with_index do |line, i|
68
+ if line =~ /^#\s*SPDX-FileCopyrightText:\s*(\d{4})\s+(.+)$/
69
+ copyrights << { year: $1.to_i, holder: $2.strip }
70
+ found_spdx = true
71
+ # REUSE-IgnoreStart
72
+ elsif line =~ /^#\s*SPDX-License-Identifier:\s*(.+)$/
73
+ # REUSE-IgnoreEnd
74
+ license = $1.strip
75
+ found_spdx = true
76
+ elsif line =~ /^#\+\+\s*$/ && found_spdx
77
+ header_end = i
78
+ break
79
+ end
80
+ end
81
+
82
+ return nil if copyrights.empty? && license.nil?
83
+
84
+ { end_line: header_end || 0, copyrights:, license: }
85
+ end
86
+
87
+ # Updates or adds SPDX headers for a single Ruby file.
88
+ #
89
+ # Each file needs correct license headers with accurate copyright years.
90
+ # Processing involves reading, parsing, querying git, and rewriting. This
91
+ # function orchestrates that workflow.
92
+ #
93
+ # [filepath] Path to the Ruby file.
94
+ def process_file(filepath)
95
+ content = File.read(filepath)
96
+ lines = content.lines
97
+
98
+ target_license = license_for_file(filepath)
99
+
100
+ # Get contributors from git for year lookups
101
+ all_contributors = LicenseUtils.get_contributors_for_lines(filepath)
102
+ your_year = LicenseUtils.get_your_latest_year(filepath, YOUR_IDENTIFIERS)
103
+
104
+ existing = parse_existing_header(lines)
105
+
106
+ if existing
107
+ # File has existing header - only update years for EXISTING contributors
108
+ needs_update = false
109
+ updated_copyrights = []
110
+
111
+ existing[:copyrights].each do |c|
112
+ # Find this contributor's latest year from git
113
+ git_year = nil
114
+ all_contributors.each do |contributor, year|
115
+ if c[:holder].split.any? { |word| contributor.include?(word) }
116
+ git_year = [git_year || 0, year].max
117
+ end
118
+ end
119
+
120
+ if git_year && git_year != c[:year]
121
+ puts " Updated #{c[:holder].split.first}'s copyright year: #{c[:year]} -> #{git_year}"
122
+ updated_copyrights << { year: git_year, holder: c[:holder] }
123
+ needs_update = true
124
+ else
125
+ updated_copyrights << c
126
+ end
127
+ end
128
+
129
+ # Check if YOUR year needs updating (if you're a contributor)
130
+ your_existing = updated_copyrights.find { |c| YOUR_IDENTIFIERS.any? { |id| c[:holder].include?(id) } }
131
+ if your_existing.nil?
132
+ puts " Adding your copyright"
133
+ updated_copyrights << { year: your_year, holder: YOUR_COPYRIGHT }
134
+ needs_update = true
135
+ end
136
+
137
+ # Check license
138
+ if existing[:license] != target_license
139
+ puts " Fixing license: #{existing[:license]} -> #{target_license}"
140
+ needs_update = true
141
+ end
142
+
143
+ if needs_update
144
+ frozen_string = lines[0].include?("frozen_string_literal") ? lines[0] : nil
145
+
146
+ header_lines = []
147
+ header_lines << "# frozen_string_literal: true\n" unless frozen_string
148
+ header_lines << "\n" if frozen_string.nil? && !lines[0].strip.empty?
149
+ header_lines << "#--\n"
150
+
151
+ # REUSE-IgnoreStart
152
+ updated_copyrights.each do |c|
153
+ header_lines << "# SPDX-FileCopyrightText: #{c[:year]} #{c[:holder]}\n"
154
+ end
155
+ header_lines << "# SPDX-License-Identifier: #{target_license}\n"
156
+ # REUSE-IgnoreEnd
157
+ header_lines << "#++\n"
158
+
159
+ content_start = existing[:end_line] + 1
160
+ while content_start < lines.length && lines[content_start].strip.empty?
161
+ content_start += 1
162
+ end
163
+
164
+ remaining = lines[content_start..]
165
+
166
+ new_content = if frozen_string
167
+ "#{frozen_string}\n#{header_lines.join}\n#{remaining.join}"
168
+ else
169
+ "#{header_lines.join}\n#{remaining.join}"
170
+ end
171
+
172
+ File.write(filepath, new_content)
173
+ puts "Updated: #{filepath}"
174
+ end
175
+ else
176
+ # No header - add one with YOUR copyright only
177
+ frozen_line = lines[0]&.include?("frozen_string_literal") ? lines.shift : nil
178
+
179
+ header = []
180
+ header << "# frozen_string_literal: true\n\n" unless frozen_line
181
+ header << "#--\n"
182
+ # REUSE-IgnoreStart
183
+ header << "# SPDX-FileCopyrightText: #{your_year} #{YOUR_COPYRIGHT}\n"
184
+ header << "# SPDX-License-Identifier: #{target_license}\n"
185
+ # REUSE-IgnoreEnd
186
+ header << "#++\n\n"
187
+
188
+ if frozen_line
189
+ File.write(filepath, "#{frozen_line}\n#{header.join}#{lines.join}")
190
+ else
191
+ File.write(filepath, header.join + lines.join)
192
+ end
193
+ puts "Added header: #{filepath}"
194
+ end
195
+ end
196
+
197
+ # Finds Ruby files to process.
198
+ #
199
+ # License automation runs on file sets. Users may specify paths or want all
200
+ # lib/ext/test files. This handles both cases using git ls-files for tracking.
201
+ #
202
+ # [paths] Explicit paths to process, or empty for default directories.
203
+ def find_rb_files(paths)
204
+ if paths.empty?
205
+ # Process all relevant directories
206
+ dirs = %w[lib ext test examples tasks bin sig]
207
+ files = dirs.flat_map do |dir|
208
+ # Include both root files and subdirectory files, for both .rb and .rbs
209
+ %w[rb rbs].flat_map do |ext|
210
+ root_files = `git ls-files '#{dir}/*.#{ext}' 2>/dev/null`.split("\n")
211
+ sub_files = `git ls-files '#{dir}/**/*.#{ext}' 2>/dev/null`.split("\n")
212
+ root_files + sub_files
213
+ end
214
+ end
215
+ files.uniq
216
+ else
217
+ paths.flat_map do |path|
218
+ if File.directory?(path)
219
+ rb_files = `git ls-files '#{path}/**/*.rb'`.split("\n")
220
+ rbs_files = `git ls-files '#{path}/**/*.rbs'`.split("\n")
221
+ rb_files + rbs_files
222
+ else
223
+ path
224
+ end
225
+ end
226
+ end
227
+ end
228
+
229
+ if __FILE__ == $0
230
+ paths = ARGV.empty? ? [] : ARGV
231
+ files = find_rb_files(paths)
232
+
233
+ files.each do |file|
234
+ process_file(file)
235
+ end
236
+ end