ratatui_ruby-devtools 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.builds/ruby-4.0.yml +38 -0
- data/.pre-commit-config.yaml +16 -0
- data/.rubocop.yml +8 -0
- data/AGENTS.md +72 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE +661 -0
- data/LICENSES/AGPL-3.0-or-later.txt +661 -0
- data/LICENSES/CC-BY-SA-4.0.txt +427 -0
- data/LICENSES/CC0-1.0.txt +121 -0
- data/LICENSES/MIT-0.txt +16 -0
- data/LICENSES/MIT.txt +18 -0
- data/README.md +199 -0
- data/REUSE.toml +18 -0
- data/Rakefile +13 -0
- data/bin/agent_rake +13 -0
- data/bin/announce +13 -0
- data/bin/console +14 -0
- data/bin/consolidate_md +13 -0
- data/bin/hbs +13 -0
- data/bin/setup +17 -0
- data/doc/contributors/documentation_style.md +121 -0
- data/doc/custom.css +22 -0
- data/exe/agent_rake +96 -0
- data/exe/announce +1120 -0
- data/exe/consolidate_md +246 -0
- data/exe/hbs +670 -0
- data/exe/scaffold +662 -0
- data/lib/ratatui_ruby/devtools/tasks/autodoc/examples.rb +133 -0
- data/lib/ratatui_ruby/devtools/tasks/autodoc/member.rb +116 -0
- data/lib/ratatui_ruby/devtools/tasks/autodoc/name.rb +33 -0
- data/lib/ratatui_ruby/devtools/tasks/autodoc.rake +21 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/cargo_lockfile.rb +38 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/changelog.rb +67 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/header.rb +43 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/history.rb +50 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/links.rb +78 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/manifest.rb +63 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/ruby_gem.rb +77 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/sem_ver.rb +63 -0
- data/lib/ratatui_ruby/devtools/tasks/bump/unreleased_section.rb +75 -0
- data/lib/ratatui_ruby/devtools/tasks/bump.rake +80 -0
- data/lib/ratatui_ruby/devtools/tasks/cargo.rake +47 -0
- data/lib/ratatui_ruby/devtools/tasks/doc.rake +887 -0
- data/lib/ratatui_ruby/devtools/tasks/example_viewer.html.erb +172 -0
- data/lib/ratatui_ruby/devtools/tasks/license/headers_md.rb +276 -0
- data/lib/ratatui_ruby/devtools/tasks/license/headers_rb.rb +236 -0
- data/lib/ratatui_ruby/devtools/tasks/license/license_utils.rb +143 -0
- data/lib/ratatui_ruby/devtools/tasks/license/snippets_md.rb +353 -0
- data/lib/ratatui_ruby/devtools/tasks/license/snippets_rdoc.rb +186 -0
- data/lib/ratatui_ruby/devtools/tasks/license.rake +91 -0
- data/lib/ratatui_ruby/devtools/tasks/lint.rake +84 -0
- data/lib/ratatui_ruby/devtools/tasks/rdoc_config.rb +45 -0
- data/lib/ratatui_ruby/devtools/tasks/resources/build.yml.erb +54 -0
- data/lib/ratatui_ruby/devtools/tasks/resources/rubies.yml +7 -0
- data/lib/ratatui_ruby/devtools/tasks/reuse.rake +104 -0
- data/lib/ratatui_ruby/devtools/tasks/sourcehut.rake +94 -0
- data/lib/ratatui_ruby/devtools/tasks/test.rake +18 -0
- data/lib/ratatui_ruby/devtools/templates/.builds/ruby.yml.erb +47 -0
- data/lib/ratatui_ruby/devtools/templates/.gitignore.erb +18 -0
- data/lib/ratatui_ruby/devtools/templates/.pre-commit-config.yaml.erb +16 -0
- data/lib/ratatui_ruby/devtools/templates/.rubocop.yml.erb +8 -0
- data/lib/ratatui_ruby/devtools/templates/AGENTS.md.erb +65 -0
- data/lib/ratatui_ruby/devtools/templates/CHANGELOG.md.erb +18 -0
- data/lib/ratatui_ruby/devtools/templates/Gemfile.erb +32 -0
- data/lib/ratatui_ruby/devtools/templates/README.md.erb +127 -0
- data/lib/ratatui_ruby/devtools/templates/REUSE.toml.erb +33 -0
- data/lib/ratatui_ruby/devtools/templates/Rakefile.erb +29 -0
- data/lib/ratatui_ruby/devtools/templates/bin/console.erb +18 -0
- data/lib/ratatui_ruby/devtools/templates/bin/setup.erb +24 -0
- data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_architecture.md.erb +16 -0
- data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_testing.md.erb +49 -0
- data/lib/ratatui_ruby/devtools/templates/doc/custom.css.erb +24 -0
- data/lib/ratatui_ruby/devtools/templates/doc/getting_started/quickstart.md.erb +56 -0
- data/lib/ratatui_ruby/devtools/templates/doc/images/.gitkeep +0 -0
- data/lib/ratatui_ruby/devtools/templates/doc/index.md.erb +25 -0
- data/lib/ratatui_ruby/devtools/templates/exe/.gitkeep +0 -0
- data/lib/ratatui_ruby/devtools/templates/gemspec.erb +58 -0
- data/lib/ratatui_ruby/devtools/templates/mise.toml.erb +12 -0
- data/lib/ratatui_ruby/devtools/templates/tasks/example_viewer.html.erb +174 -0
- data/lib/ratatui_ruby/devtools/templates/tasks/resources/build.yml.erb +62 -0
- data/lib/ratatui_ruby/devtools/templates/tasks/resources/index.html.erb +46 -0
- data/lib/ratatui_ruby/devtools/templates/tasks/resources/rubies.yml.erb +9 -0
- data/lib/ratatui_ruby/devtools/templates/vendor/goodcop/base.yml +1047 -0
- data/lib/ratatui_ruby/devtools/version.rb +13 -0
- data/lib/ratatui_ruby/devtools.rb +137 -0
- data/mise.toml +7 -0
- data/sig/ratatui_ruby/devtools.rbs +15 -0
- data/vendor/goodcop/base.yml +1047 -0
- metadata +252 -0
|
@@ -0,0 +1,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
|