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,143 @@
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
+ # Shared utility for detecting contributors from git blame and Co-Authored-By trailers.
9
+ #
10
+ # This module provides methods to:
11
+ # - Get all contributors (authors and co-authors) who touched specific lines
12
+ # - Track the latest year each contributor touched those lines
13
+ # - Parse Co-Authored-By trailers from commit messages
14
+
15
+ require "open3"
16
+ require "date"
17
+
18
+ # Extracts contributor information from git history.
19
+ #
20
+ # License headers need accurate copyright years and contributor names. Git
21
+ # blame provides line-level authorship. Commit messages contain Co-Authored-By
22
+ # trailers. Combining these sources manually is tedious.
23
+ #
24
+ # This module queries git blame and parses commit messages. It returns
25
+ # contributor names with their latest modification years. Use it when
26
+ # generating or updating SPDX headers.
27
+ module LicenseUtils
28
+ # A contributor with their latest modification year.
29
+ #
30
+ # [name] The contributor's display name.
31
+ # [email] The contributor's email address.
32
+ # [year] The most recent year they modified the file.
33
+ Contributor = Data.define(:name, :email, :year)
34
+
35
+ class << self
36
+ # Get all contributors who touched lines in a file (or range of lines).
37
+ # Returns a Hash of { "Name <email>" => year } mapping each contributor to their latest year.
38
+ #
39
+ # This considers both the commit author AND any Co-Authored-By trailers in commit messages.
40
+ def get_contributors_for_lines(filepath, start_line = nil, end_line = nil)
41
+ blame_cmd = if start_line && end_line
42
+ %W[git blame -L #{start_line},#{end_line} --porcelain -- #{filepath}]
43
+ else
44
+ %W[git blame --porcelain -- #{filepath}]
45
+ end
46
+
47
+ output, _status = Open3.capture2(*blame_cmd)
48
+
49
+ contributors = {} # "Name <email>" => year
50
+ commit_cache = {} # commit_hash => { year:, author:, co_authors: [] }
51
+
52
+ current_commit = nil
53
+
54
+ output.each_line do |line|
55
+ if line =~ /^([a-f0-9]{40})/
56
+ current_commit = $1
57
+ elsif line =~ /^author (.+)$/
58
+ commit_cache[current_commit] ||= {}
59
+ commit_cache[current_commit][:author_name] = $1
60
+ elsif line =~ /^author-mail <(.+)>$/
61
+ commit_cache[current_commit] ||= {}
62
+ commit_cache[current_commit][:author_email] = $1
63
+ elsif line =~ /^author-time (\d+)$/
64
+ commit_cache[current_commit] ||= {}
65
+ timestamp = $1.to_i
66
+ commit_cache[current_commit][:year] = Time.at(timestamp).year
67
+ end
68
+ end
69
+
70
+ # Now fetch co-authors for each unique commit
71
+ commit_cache.each do |commit_hash, data|
72
+ next if commit_hash == "0" * 40 # Skip uncommitted lines
73
+
74
+ # Get commit message for Co-Authored-By parsing
75
+ msg_output, _status = Open3.capture2("git", "log", "-1", "--format=%B", commit_hash)
76
+ co_authors = parse_co_authors(msg_output)
77
+ data[:co_authors] = co_authors
78
+
79
+ # Add author
80
+ if data[:author_name] && data[:author_email]
81
+ key = "#{data[:author_name]} <#{data[:author_email]}>"
82
+ year = data[:year] || Date.today.year
83
+ contributors[key] = [contributors[key] || 0, year].max
84
+ end
85
+
86
+ # Add co-authors with same year as commit
87
+ co_authors.each do |ca|
88
+ key = "#{ca[:name]} <#{ca[:email]}>"
89
+ year = data[:year] || Date.today.year
90
+ contributors[key] = [contributors[key] || 0, year].max
91
+ end
92
+ end
93
+
94
+ contributors
95
+ end
96
+
97
+ # Get YOUR latest year contribution to the file/lines.
98
+ # your_identifiers should be an array of strings that identify you (name, email fragments).
99
+ def get_your_latest_year(filepath, your_identifiers, start_line = nil, end_line = nil)
100
+ contributors = get_contributors_for_lines(filepath, start_line, end_line)
101
+
102
+ your_year = nil
103
+ contributors.each do |contributor, year|
104
+ if your_identifiers.any? { |id| contributor.include?(id) }
105
+ your_year = [your_year || 0, year].max
106
+ end
107
+ end
108
+
109
+ your_year || Date.today.year
110
+ end
111
+
112
+ # Get all contributors EXCEPT you, with their latest years.
113
+ # Returns array of { name:, email:, year: }
114
+ def get_other_contributors(filepath, your_identifiers, start_line = nil, end_line = nil)
115
+ contributors = get_contributors_for_lines(filepath, start_line, end_line)
116
+
117
+ others = []
118
+ contributors.each do |contributor, year|
119
+ next if your_identifiers.any? { |id| contributor.include?(id) }
120
+
121
+ # Parse "Name <email>" format
122
+ if contributor =~ /^(.+?)\s*<(.+)>$/
123
+ others << { name: $1.strip, email: $2.strip, year: }
124
+ end
125
+ end
126
+
127
+ others
128
+ end
129
+
130
+ private def parse_co_authors(message)
131
+ co_authors = []
132
+
133
+ message.each_line do |line|
134
+ # Match "Co-Authored-By: Name <email>" (case insensitive)
135
+ if line =~ /^Co-Authored-By:\s*(.+?)\s*<(.+?)>\s*$/i
136
+ co_authors << { name: $1.strip, email: $2.strip }
137
+ end
138
+ end
139
+
140
+ co_authors
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,353 @@
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 add SPDX snippet headers to fenced code blocks in markdown files.
9
+ #
10
+ # Usage: ruby tasks/license/snippets_md.rb [path...]
11
+ #
12
+ # If no paths are given, processes all .md files via git ls-files.
13
+ #
14
+ # Rules:
15
+ # - Wraps all fenced code blocks (``` or ````) with SPDX snippet headers (MIT-0)
16
+ # - For SYNC:START/SYNC:END blocks, wraps AROUND the sync markers (not inside)
17
+ # - Uses git blame to determine the latest edit year for the code lines
18
+ # - Skips blocks that are already properly wrapped with MIT-0 and Kerrick Long
19
+ # - Removes malformed existing SPDX-Snippet blocks and replaces with correct ones
20
+
21
+ require "open3"
22
+ require "date"
23
+
24
+ # The name for SPDX-FileCopyrightText in code snippets.
25
+ COPYRIGHT_HOLDER = "Kerrick Long"
26
+
27
+ # The SPDX license identifier for code snippets.
28
+ LICENSE = "MIT-0"
29
+
30
+ # Files to skip entirely (relative paths from repo root)
31
+ EXCLUDED_FILES = [
32
+ "doc/contributors/v1.0.0_blockers.md",
33
+ "doc/contributors/upstream_requests/tab_rects.md",
34
+ "doc/contributors/upstream_requests/title_rects.md",
35
+ ].freeze
36
+
37
+ # Determines the latest edit year for a line range using git blame.
38
+ #
39
+ # Copyright years come from when code was last modified. Git blame provides
40
+ # per-line authorship. Extract and return the most recent year.
41
+ #
42
+ # [file] Path to the file.
43
+ # [start_line] First line number (1-indexed).
44
+ # [end_line] Last line number (1-indexed).
45
+ def get_latest_git_year(file, start_line, end_line)
46
+ cmd = %W[git blame -L #{start_line},#{end_line} --date=short -- #{file}]
47
+ output, _status = Open3.capture2(*cmd)
48
+ years = output.scan(/(\d{4})-\d{2}-\d{2}/).flatten.map(&:to_i)
49
+ years.empty? ? Date.today.year : years.max
50
+ end
51
+
52
+ # Checks if an existing SPDX snippet header matches our required format.
53
+ #
54
+ # Already-correct snippets should be skipped. Re-wrapping wastes time and
55
+ # creates noisy diffs. This function validates existing headers.
56
+ #
57
+ # [lines] Array of line strings.
58
+ # [idx] Index of the SPDX-SnippetBegin line.
59
+ def is_our_snippet_header?(lines, idx)
60
+ # Check if the current SPDX-SnippetBegin block already has our copyright/license
61
+ i = idx + 1
62
+ has_our_copyright = false
63
+ has_mit0 = false
64
+
65
+ while i < lines.length && !lines[i].include?("-->")
66
+ line = lines[i]
67
+ has_our_copyright = true if line.include?(COPYRIGHT_HOLDER) && line.include?("SPDX-FileCopyrightText")
68
+ has_mit0 = true if line.include?("MIT-0") && line.include?("SPDX-License-Identifier")
69
+ i += 1
70
+ end
71
+
72
+ has_our_copyright && has_mit0
73
+ end
74
+
75
+ # Locates the SPDX-SnippetEnd marker for a snippet block.
76
+ #
77
+ # Snippet blocks have paired begin/end markers. Removing or replacing a block
78
+ # requires finding both. This scans forward from a start position.
79
+ #
80
+ # [lines] Array of line strings.
81
+ # [start_idx] Index to start searching from.
82
+ def find_snippet_end(lines, start_idx)
83
+ i = start_idx
84
+ while i < lines.length
85
+ return i if lines[i].include?("SPDX-SnippetEnd")
86
+ i += 1
87
+ end
88
+ nil
89
+ end
90
+
91
+ # Wraps code blocks in a markdown file with SPDX snippet headers.
92
+ #
93
+ # Each code block needs MIT-0 licensing. Processing involves scanning for
94
+ # fenced blocks, removing malformed existing headers, and inserting correct
95
+ # ones. This function orchestrates that workflow.
96
+ #
97
+ # [filepath] Path to the markdown file.
98
+ def process_file(filepath)
99
+ # Skip excluded files
100
+ return if EXCLUDED_FILES.any? { |excluded| filepath.end_with?(excluded) }
101
+
102
+ content = File.read(filepath)
103
+ lines = content.lines
104
+
105
+ # Track code block ranges (to exclude from file header year calculation)
106
+ code_block_ranges = []
107
+ changes = []
108
+ removals = [] # existing malformed SPDX snippet blocks to remove
109
+ i = 0
110
+
111
+ while i < lines.length
112
+ line = lines[i]
113
+
114
+ # Check if we're at an existing SPDX-SnippetBegin
115
+ if line.include?("SPDX-SnippetBegin")
116
+ snippet_start = i
117
+ snippet_end = find_snippet_end(lines, i)
118
+
119
+ if snippet_end
120
+ # Check if this is already our proper snippet
121
+ if is_our_snippet_header?(lines, i)
122
+ # Skip this block entirely - it's already correct
123
+ i = snippet_end + 1
124
+ next
125
+ else
126
+ # Mark for removal - we'll re-wrap the inner content
127
+ removals << { start: snippet_start, end: snippet_end }
128
+ i = snippet_end + 1
129
+ next
130
+ end
131
+ end
132
+ end
133
+
134
+ # Check for SYNC:START pattern
135
+ if line =~ /<!--\s*SYNC:START/
136
+ sync_start_line = i
137
+ j = i + 1
138
+ code_start = nil
139
+ code_end = nil
140
+ sync_end_line = nil
141
+
142
+ while j < lines.length
143
+ if lines[j] =~ /^(````*)(\w*)$/
144
+ if code_start.nil?
145
+ code_start = j
146
+ else
147
+ code_end = j
148
+ end
149
+ elsif lines[j] =~ /<!--\s*SYNC:END/
150
+ sync_end_line = j
151
+ break
152
+ end
153
+ j += 1
154
+ end
155
+
156
+ if code_start && code_end && sync_end_line
157
+ year = get_latest_git_year(filepath, code_start + 1, code_end + 1)
158
+ changes << {
159
+ type: :sync_block,
160
+ start: sync_start_line,
161
+ end: sync_end_line,
162
+ year:,
163
+ }
164
+ code_block_ranges << (code_start..code_end)
165
+ i = sync_end_line + 1
166
+ next
167
+ end
168
+ end
169
+
170
+ # Check for standalone fenced code block
171
+ if line =~ /^(````*)(\w*)$/
172
+ fence_marker = $1
173
+ fence_start = i
174
+ re_end = /^#{Regexp.escape(fence_marker)}$/
175
+
176
+ j = i + 1
177
+ fence_end = nil
178
+ while j < lines.length
179
+ if lines[j] =~ re_end
180
+ fence_end = j
181
+ break
182
+ end
183
+ j += 1
184
+ end
185
+
186
+ if fence_end
187
+ year = get_latest_git_year(filepath, fence_start + 1, fence_end + 1)
188
+ changes << {
189
+ type: :code_block,
190
+ start: fence_start,
191
+ end: fence_end,
192
+ year:,
193
+ }
194
+ code_block_ranges << (fence_start..fence_end)
195
+ i = fence_end + 1
196
+ next
197
+ end
198
+ end
199
+
200
+ i += 1
201
+ end
202
+
203
+ # Handle removals and additions
204
+ has_changes = !changes.empty? || !removals.empty?
205
+
206
+ # Remove existing malformed SPDX blocks (in reverse order)
207
+ removals.sort_by { |r| -r[:start] }.each do |removal|
208
+ # Remove the SnippetEnd line
209
+ lines.delete_at(removal[:end])
210
+ # Remove lines from SnippetBegin through the --> closing the comment
211
+ close_idx = removal[:start]
212
+ while close_idx < lines.length && !lines[close_idx].include?("-->")
213
+ close_idx += 1
214
+ end
215
+ # Remove from start to close_idx inclusive
216
+ (close_idx - removal[:start] + 1).times { lines.delete_at(removal[:start]) }
217
+ end
218
+
219
+ # Recalculate content after removals
220
+ content = lines.join
221
+ lines = content.lines
222
+
223
+ # Re-scan for code blocks that need wrapping
224
+ changes = []
225
+ i = 0
226
+
227
+ while i < lines.length
228
+ line = lines[i]
229
+
230
+ # Skip if already inside an SPDX-SnippetBegin block
231
+ if line.include?("SPDX-SnippetBegin")
232
+ while i < lines.length && !lines[i].include?("SPDX-SnippetEnd")
233
+ i += 1
234
+ end
235
+ i += 1
236
+ next
237
+ end
238
+
239
+ # Check for SYNC:START pattern
240
+ if line =~ /<!--\s*SYNC:START/
241
+ sync_start_line = i
242
+ j = i + 1
243
+ code_start = nil
244
+ code_end = nil
245
+ sync_end_line = nil
246
+
247
+ while j < lines.length
248
+ if lines[j] =~ /^(````*)(\w*)$/
249
+ if code_start.nil?
250
+ code_start = j
251
+ else
252
+ code_end = j
253
+ end
254
+ elsif lines[j] =~ /<!--\s*SYNC:END/
255
+ sync_end_line = j
256
+ break
257
+ end
258
+ j += 1
259
+ end
260
+
261
+ if code_start && code_end && sync_end_line
262
+ year = get_latest_git_year(filepath, code_start + 1, code_end + 1)
263
+ changes << {
264
+ type: :sync_block,
265
+ start: sync_start_line,
266
+ end: sync_end_line,
267
+ year:,
268
+ }
269
+ i = sync_end_line + 1
270
+ next
271
+ end
272
+ end
273
+
274
+ # Check for standalone fenced code block
275
+ if line =~ /^(````*)(\w*)$/
276
+ fence_marker = $1
277
+ fence_start = i
278
+ re_end = /^#{Regexp.escape(fence_marker)}$/
279
+
280
+ j = i + 1
281
+ fence_end = nil
282
+ while j < lines.length
283
+ if lines[j] =~ re_end
284
+ fence_end = j
285
+ break
286
+ end
287
+ j += 1
288
+ end
289
+
290
+ if fence_end
291
+ year = get_latest_git_year(filepath, fence_start + 1, fence_end + 1)
292
+ changes << {
293
+ type: :code_block,
294
+ start: fence_start,
295
+ end: fence_end,
296
+ year:,
297
+ }
298
+ i = fence_end + 1
299
+ next
300
+ end
301
+ end
302
+
303
+ i += 1
304
+ end
305
+
306
+ return if changes.empty? && !has_changes
307
+
308
+ # Apply changes in reverse order to preserve line numbers
309
+ changes.sort_by { |c| -c[:start] }.each do |change|
310
+ # REUSE-IgnoreStart
311
+ snippet_begin = "<!-- SPDX-SnippetBegin -->\n<!--\n SPDX-FileCopyrightText: #{change[:year]} #{COPYRIGHT_HOLDER}\n SPDX-License-Identifier: #{LICENSE}\n-->\n"
312
+ snippet_end = "<!-- SPDX-SnippetEnd -->\n"
313
+ # REUSE-IgnoreEnd
314
+
315
+ # Insert end marker after the block
316
+ lines.insert(change[:end] + 1, snippet_end)
317
+ # Insert begin marker before the block
318
+ lines.insert(change[:start], snippet_begin)
319
+ end
320
+
321
+ File.write(filepath, lines.join)
322
+ puts "Updated: #{filepath} (#{changes.length} code block(s))"
323
+ end
324
+
325
+ # Finds markdown files to process.
326
+ #
327
+ # License automation runs on file sets. Users may specify paths or want all
328
+ # files. This handles both cases using git ls-files for tracking.
329
+ #
330
+ # [paths] Explicit paths to process, or empty for all tracked .md files.
331
+ def find_md_files(paths)
332
+ # Use git ls-files to respect .gitignore
333
+ if paths.empty?
334
+ `git ls-files '*.md'`.split("\n")
335
+ else
336
+ paths.flat_map do |path|
337
+ if File.directory?(path)
338
+ `git ls-files '#{path}/**/*.md'`.split("\n")
339
+ else
340
+ path
341
+ end
342
+ end
343
+ end
344
+ end
345
+
346
+ if __FILE__ == $0
347
+ paths = ARGV.empty? ? [] : ARGV
348
+ files = find_md_files(paths)
349
+
350
+ files.each do |file|
351
+ process_file(file)
352
+ end
353
+ end
@@ -0,0 +1,186 @@
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 add SPDX snippet headers to RDoc code examples in Ruby files.
9
+ #
10
+ # Usage: ruby scripts/add_spdx_rdoc_snippets.rb [path...]
11
+ #
12
+ # If no paths are given, processes all .rb files via git ls-files.
13
+ #
14
+ # Rules:
15
+ # - Wraps RDoc code examples (indented comment lines) with SPDX snippet headers
16
+ # - Uses #-- and #++ to hide the SPDX headers from RDoc rendering
17
+ # - Uses git blame to determine the latest edit year for the code lines
18
+ # - Skips blocks that are already wrapped with SPDX-SnippetBegin
19
+
20
+ require "open3"
21
+ require "date"
22
+
23
+ COPYRIGHT_HOLDER = "Kerrick Long"
24
+ LICENSE = "MIT-0"
25
+
26
+ # Determines the latest edit year for a line range using git blame.
27
+ #
28
+ # Copyright years come from when code was last modified. Git blame provides
29
+ # per-line authorship. Extract and return the most recent year.
30
+ #
31
+ # [file] Path to the file.
32
+ # [start_line] First line number (1-indexed).
33
+ # [end_line] Last line number (1-indexed).
34
+ def get_latest_git_year(file, start_line, end_line)
35
+ cmd = %W[git blame -L #{start_line},#{end_line} --date=short -- #{file}]
36
+ output, _status = Open3.capture2(*cmd)
37
+ years = output.scan(/(\d{4})-\d{2}-\d{2}/).flatten.map(&:to_i)
38
+ years.empty? ? Date.today.year : years.max
39
+ end
40
+
41
+ # Identifies RDoc code blocks in Ruby source files.
42
+ #
43
+ # RDoc code examples are indented comment lines. They need MIT-0 licensing
44
+ # separate from the file. This scans for the indentation pattern that
45
+ # identifies code blocks.
46
+ #
47
+ # [lines] Array of line strings from the file.
48
+ def find_rdoc_code_blocks(lines)
49
+ # Find all RDoc code blocks (indented comment lines)
50
+ # Returns array of {start:, end:, indent:} where indent is the comment prefix
51
+ blocks = []
52
+ i = 0
53
+
54
+ while i < lines.length
55
+ line = lines[i]
56
+
57
+ # Check if this is an indented code line in a comment
58
+ # Pattern: optional leading whitespace, #, then 3+ spaces (RDoc code indent)
59
+ if line =~ /^(\s*)#( +)(\S.*)$/
60
+ prefix = $1 # leading whitespace before #
61
+ block_start = i
62
+
63
+ # Find the extent of this code block
64
+ j = i
65
+ while j < lines.length
66
+ current = lines[j]
67
+ # Code block continues if line is indented code OR empty comment line
68
+ if current =~ /^#{Regexp.escape(prefix)}#( +|\s*$)/
69
+ j += 1
70
+ else
71
+ break
72
+ end
73
+ end
74
+
75
+ block_end = j - 1
76
+
77
+ # Only count as a block if it has actual code (not just empty lines)
78
+ has_code = (block_start..block_end).any? { |k| lines[k] =~ /^#{Regexp.escape(prefix)}# +\S/ }
79
+
80
+ if has_code && block_end > block_start
81
+ blocks << { start: block_start, end: block_end, prefix: }
82
+ end
83
+
84
+ i = j
85
+ else
86
+ i += 1
87
+ end
88
+ end
89
+
90
+ blocks
91
+ end
92
+
93
+ # Checks if a code block already has SPDX snippet headers.
94
+ #
95
+ # Already-wrapped blocks should be skipped. Re-wrapping wastes time and
96
+ # creates noisy diffs. This checks for the #++ marker before a block.
97
+ #
98
+ # [lines] Array of line strings.
99
+ # [block_start] Index of the code block start.
100
+ # [prefix] The indentation prefix for this block.
101
+ def is_already_wrapped?(lines, block_start, prefix)
102
+ # Check if the line before the block is #++ (meaning it's already wrapped)
103
+ return false if block_start < 1
104
+
105
+ prev_line = lines[block_start - 1]
106
+ prev_line =~ /^#{Regexp.escape(prefix)}#\+\+\s*$/
107
+ end
108
+
109
+ # Wraps RDoc code blocks in a Ruby file with SPDX snippet headers.
110
+ #
111
+ # Each code example needs MIT-0 licensing. Processing involves scanning for
112
+ # indented examples and inserting hidden SPDX headers. This function
113
+ # orchestrates that workflow.
114
+ #
115
+ # [filepath] Path to the Ruby file.
116
+ def process_file(filepath)
117
+ content = File.read(filepath)
118
+ lines = content.lines
119
+
120
+ blocks = find_rdoc_code_blocks(lines)
121
+
122
+ # Filter out already-wrapped blocks
123
+ blocks.reject! { |b| is_already_wrapped?(lines, b[:start], b[:prefix]) }
124
+
125
+ return if blocks.empty?
126
+
127
+ # Apply changes in reverse order to preserve line numbers
128
+ blocks.sort_by { |b| -b[:start] }.each do |block|
129
+ year = get_latest_git_year(filepath, block[:start] + 1, block[:end] + 1)
130
+ prefix = block[:prefix]
131
+
132
+ # Build the wrapper lines
133
+ # REUSE-IgnoreStart
134
+ begin_wrapper = [
135
+ "#{prefix}#--\n",
136
+ "#{prefix}# SPDX-SnippetBegin\n",
137
+ "#{prefix}# SPDX-FileCopyrightText: #{year} #{COPYRIGHT_HOLDER}\n",
138
+ "#{prefix}# SPDX-License-Identifier: #{LICENSE}\n",
139
+ "#{prefix}#++\n",
140
+ ]
141
+
142
+ end_wrapper = [
143
+ "#{prefix}#--\n",
144
+ "#{prefix}# SPDX-SnippetEnd\n",
145
+ "#{prefix}#++\n",
146
+ ]
147
+ # REUSE-IgnoreEnd
148
+
149
+ # Insert end wrapper after the block
150
+ lines.insert(block[:end] + 1, *end_wrapper)
151
+ # Insert begin wrapper before the block
152
+ lines.insert(block[:start], *begin_wrapper)
153
+ end
154
+
155
+ File.write(filepath, lines.join)
156
+ puts "Updated: #{filepath} (#{blocks.length} code block(s))"
157
+ end
158
+
159
+ # Finds Ruby files to process.
160
+ #
161
+ # License automation runs on file sets. Users may specify paths or want all
162
+ # files. This handles both cases using git ls-files for tracking.
163
+ #
164
+ # [paths] Explicit paths to process, or empty for all tracked .rb files.
165
+ def find_rb_files(paths)
166
+ if paths.empty?
167
+ `git ls-files '*.rb'`.split("\n")
168
+ else
169
+ paths.flat_map do |path|
170
+ if File.directory?(path)
171
+ `git ls-files '#{path}/**/*.rb'`.split("\n")
172
+ else
173
+ path
174
+ end
175
+ end
176
+ end
177
+ end
178
+
179
+ if __FILE__ == $0
180
+ paths = ARGV.empty? ? [] : ARGV
181
+ files = find_rb_files(paths)
182
+
183
+ files.each do |file|
184
+ process_file(file)
185
+ end
186
+ end