Top Level Namespace

Defined Under Namespace

Modules: Autodoc, LicenseUtils, RDocConfig, RatatuiRuby Classes: CargoLockfile, Changelog, Header, History, Links, Manifest, RubyGem, SemVer, UnreleasedSection

Constant Summary collapse

YOUR_NAME =

Your name for copyright headers.

"Kerrick Long"
YOUR_EMAIL =

Your email for copyright headers.

"[email protected]"
YOUR_IDENTIFIERS =

Identifiers used to match your contributions in git history.

[YOUR_NAME, YOUR_EMAIL].freeze
"#{YOUR_NAME} <#{YOUR_EMAIL}>"
LICENSE =

The SPDX license identifier for code snippets.

"MIT-0"
"Kerrick Long"
EXCLUDED_FILES =

Files to skip entirely (relative paths from repo root)

[
  "doc/contributors/v1.0.0_blockers.md",
  "doc/contributors/upstream_requests/tab_rects.md",
  "doc/contributors/upstream_requests/title_rects.md",
].freeze

Instance Method Summary collapse

Instance Method Details

#find_code_blocks(lines) ⇒ Object

Identifies fenced code blocks in markdown content.

Copyright years come from git blame. Code blocks contain pasted content, not original prose. Blaming code block lines produces wrong contributors. Exclude these ranges when calculating copyright years.

lines

Array of line strings from the file.



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/ratatui_ruby/devtools/tasks/license/headers_md.rb', line 57

def find_code_blocks(lines)
  blocks = []
  i = 0

  while i < lines.length
    line = lines[i]

    if line =~ /^(````*)(\w*)$/
      fence_marker = $1
      fence_start = i
      re_end = /^#{Regexp.escape(fence_marker)}$/

      j = i + 1
      while j < lines.length
        if lines[j] =~ re_end
          blocks << { start: fence_start, end: j }
          i = j
          break
        end
        j += 1
      end
    end

    i += 1
  end

  blocks
end

#find_md_files(paths) ⇒ Object

Finds markdown files to process.

License automation runs on file sets. Users may specify paths or want all files. This handles both cases using git ls-files for tracking.

paths

Explicit paths to process, or empty for all tracked .md files.



255
256
257
258
259
260
261
262
263
264
265
266
267
# File 'lib/ratatui_ruby/devtools/tasks/license/headers_md.rb', line 255

def find_md_files(paths)
  if paths.empty?
    `git ls-files '*.md'`.split("\n")
  else
    paths.flat_map do |path|
      if File.directory?(path)
        `git ls-files '#{path}/**/*.md'`.split("\n")
      else
        path
      end
    end
  end
end

#find_rb_files(paths) ⇒ Object

Finds Ruby files to process.

License automation runs on file sets. Users may specify paths or want all files. This handles both cases using git ls-files for tracking.

paths

Explicit paths to process, or empty for all tracked .rb files.



203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/ratatui_ruby/devtools/tasks/license/headers_rb.rb', line 203

def find_rb_files(paths)
  if paths.empty?
    # Process all relevant directories
    dirs = %w[lib ext test examples tasks bin sig]
    files = dirs.flat_map do |dir|
      # Include both root files and subdirectory files, for both .rb and .rbs
      %w[rb rbs].flat_map do |ext|
        root_files = `git ls-files '#{dir}/*.#{ext}' 2>/dev/null`.split("\n")
        sub_files = `git ls-files '#{dir}/**/*.#{ext}' 2>/dev/null`.split("\n")
        root_files + sub_files
      end
    end
    files.uniq
  else
    paths.flat_map do |path|
      if File.directory?(path)
        rb_files = `git ls-files '#{path}/**/*.rb'`.split("\n")
        rbs_files = `git ls-files '#{path}/**/*.rbs'`.split("\n")
        rb_files + rbs_files
      else
        path
      end
    end
  end
end

#find_rdoc_code_blocks(lines) ⇒ Object

Identifies RDoc code blocks in Ruby source files.

RDoc code examples are indented comment lines. They need MIT-0 licensing separate from the file. This scans for the indentation pattern that identifies code blocks.

lines

Array of line strings from the file.



48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/ratatui_ruby/devtools/tasks/license/snippets_rdoc.rb', line 48

def find_rdoc_code_blocks(lines)
  # Find all RDoc code blocks (indented comment lines)
  # Returns array of {start:, end:, indent:} where indent is the comment prefix
  blocks = []
  i = 0

  while i < lines.length
    line = lines[i]

    # Check if this is an indented code line in a comment
    # Pattern: optional leading whitespace, #, then 3+ spaces (RDoc code indent)
    if line =~ /^(\s*)#(   +)(\S.*)$/
      prefix = $1 # leading whitespace before #
      block_start = i

      # Find the extent of this code block
      j = i
      while j < lines.length
        current = lines[j]
        # Code block continues if line is indented code OR empty comment line
        if current =~ /^#{Regexp.escape(prefix)}#(   +|\s*$)/
          j += 1
        else
          break
        end
      end

      block_end = j - 1

      # Only count as a block if it has actual code (not just empty lines)
      has_code = (block_start..block_end).any? { |k| lines[k] =~ /^#{Regexp.escape(prefix)}#   +\S/ }

      if has_code && block_end > block_start
        blocks << { start: block_start, end: block_end, prefix: }
      end

      i = j
    else
      i += 1
    end
  end

  blocks
end

#find_snippet_end(lines, start_idx) ⇒ Object

Locates the SPDX-SnippetEnd marker for a snippet block.

Snippet blocks have paired begin/end markers. Removing or replacing a block requires finding both. This scans forward from a start position.

lines

Array of line strings.

start_idx

Index to start searching from.



82
83
84
85
86
87
88
89
# File 'lib/ratatui_ruby/devtools/tasks/license/snippets_md.rb', line 82

def find_snippet_end(lines, start_idx)
  i = start_idx
  while i < lines.length
    return i if lines[i].include?("SPDX-SnippetEnd")
    i += 1
  end
  nil
end

#get_latest_git_year(file, start_line, end_line) ⇒ Object

Determines the latest edit year for a line range using git blame.

Copyright years come from when code was last modified. Git blame provides per-line authorship. Extract and return the most recent year.

file

Path to the file.

start_line

First line number (1-indexed).

end_line

Last line number (1-indexed).



45
46
47
48
49
50
# File 'lib/ratatui_ruby/devtools/tasks/license/snippets_md.rb', line 45

def get_latest_git_year(file, start_line, end_line)
  cmd = %W[git blame -L #{start_line},#{end_line} --date=short -- #{file}]
  output, _status = Open3.capture2(*cmd)
  years = output.scan(/(\d{4})-\d{2}-\d{2}/).flatten.map(&:to_i)
  years.empty? ? Date.today.year : years.max
end

#get_non_code_line_ranges(lines) ⇒ Object

Calculates line ranges outside code blocks.

Copyright years come from git blame. Blaming the entire file includes code blocks. This function returns only prose ranges for accurate year lookup.

lines

Array of line strings from the file.



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/ratatui_ruby/devtools/tasks/license/headers_md.rb', line 92

def get_non_code_line_ranges(lines)
  header_end = 0
  if lines[0]&.include?("<!--")
    (0...(lines.length)).each do |i|
      if lines[i].include?("-->")
        header_end = i + 1
        break
      end
    end
  end

  code_blocks = find_code_blocks(lines)
  non_code_ranges = []
  current_line = header_end

  code_blocks.each do |block|
    if current_line < block[:start]
      non_code_ranges << [current_line + 1, block[:start]]
    end
    current_line = block[:end] + 1
  end

  if current_line < lines.length
    non_code_ranges << [current_line + 1, lines.length]
  end

  non_code_ranges
end

#is_already_wrapped?(lines, block_start, prefix) ⇒ Boolean

Checks if a code block already has SPDX snippet headers.

Already-wrapped blocks should be skipped. Re-wrapping wastes time and creates noisy diffs. This checks for the #++ marker before a block.

lines

Array of line strings.

block_start

Index of the code block start.

prefix

The indentation prefix for this block.

Returns:

  • (Boolean)


101
102
103
104
105
106
107
# File 'lib/ratatui_ruby/devtools/tasks/license/snippets_rdoc.rb', line 101

def is_already_wrapped?(lines, block_start, prefix)
  # Check if the line before the block is #++ (meaning it's already wrapped)
  return false if block_start < 1

  prev_line = lines[block_start - 1]
  prev_line =~ /^#{Regexp.escape(prefix)}#\+\+\s*$/
end

#is_our_snippet_header?(lines, idx) ⇒ Boolean

Checks if an existing SPDX snippet header matches our required format.

Already-correct snippets should be skipped. Re-wrapping wastes time and creates noisy diffs. This function validates existing headers.

lines

Array of line strings.

idx

Index of the SPDX-SnippetBegin line.

Returns:

  • (Boolean)


59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/ratatui_ruby/devtools/tasks/license/snippets_md.rb', line 59

def is_our_snippet_header?(lines, idx)
  # Check if the current SPDX-SnippetBegin block already has our copyright/license
  i = idx + 1
  has_our_copyright = false
  has_mit0 = false

  while i < lines.length && !lines[i].include?("-->")
    line = lines[i]
    has_our_copyright = true if line.include?(COPYRIGHT_HOLDER) && line.include?("SPDX-FileCopyrightText")
    has_mit0 = true if line.include?("MIT-0") && line.include?("SPDX-License-Identifier")
    i += 1
  end

  has_our_copyright && has_mit0
end

#license_for_file(filepath) ⇒ Object

Selects the appropriate license based on file location.

Different parts of the codebase have different licenses. Library code is LGPL. Examples are MIT-0 or AGPL. This function routes files to their correct license by path pattern.

filepath

Path to the Ruby file.



33
34
35
36
37
38
39
40
41
42
# File 'lib/ratatui_ruby/devtools/tasks/license/headers_rb.rb', line 33

def license_for_file(filepath)
  case filepath
  when %r{^(lib|sig/ratatui_ruby|ext|test)/}
    "LGPL-3.0-or-later"
  when %r{^(examples|sig/examples)/(widget_|verify_)}
    "MIT-0"
  else
    "AGPL-3.0-or-later"
  end
end

#parse_existing_header(lines) ⇒ Object

Extracts existing SPDX header from Ruby file content.

Files may already have headers. Updating requires parsing existing copyright holders and years. This extracts them for comparison and update.

lines

Array of line strings from the file.



127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/ratatui_ruby/devtools/tasks/license/headers_md.rb', line 127

def parse_existing_header(lines)
  return nil unless lines[0]&.include?("<!--")

  header_end = nil
  copyrights = []
  license = nil

  (0...(lines.length)).each do |i|
    line = lines[i]

    if line =~ /SPDX-FileCopyrightText:\s*(\d{4})\s+(.+)$/
      copyrights << { year: $1.to_i, holder: $2.strip }
    # REUSE-IgnoreStart
    elsif line =~ /SPDX-License-Identifier:\s*(.+)$/
      # REUSE-IgnoreEnd
      license = $1.strip
    end

    if line.include?("-->")
      header_end = i
      break
    end
  end

  return nil if header_end.nil?
  return nil if copyrights.empty? && license.nil?

  { end_line: header_end, copyrights:, license: }
end

#process_file(filepath) ⇒ Object

Wraps RDoc code blocks in a Ruby file with SPDX snippet headers.

Each code example needs MIT-0 licensing. Processing involves scanning for indented examples and inserting hidden SPDX headers. This function orchestrates that workflow.

filepath

Path to the Ruby file.



164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/ratatui_ruby/devtools/tasks/license/headers_md.rb', line 164

def process_file(filepath)
  content = File.read(filepath)
  lines = content.lines

  non_code_ranges = get_non_code_line_ranges(lines)

  # Get contributors from non-code lines for year lookups
  all_contributors = {}
  non_code_ranges.each do |start_line, end_line|
    range_contributors = LicenseUtils.get_contributors_for_lines(filepath, start_line, end_line)
    range_contributors.each do |contributor, year|
      all_contributors[contributor] = [all_contributors[contributor] || 0, year].max
    end
  end

  your_year = nil
  all_contributors.each do |contributor, year|
    if YOUR_IDENTIFIERS.any? { |id| contributor.include?(id) }
      your_year = [your_year || 0, year].max
    end
  end
  your_year ||= Date.today.year

  existing = parse_existing_header(lines)

  if existing
    # Only update years for EXISTING contributors
    needs_update = false
    updated_copyrights = []

    existing[:copyrights].each do |c|
      git_year = nil
      all_contributors.each do |contributor, year|
        if c[:holder].split.any? { |word| contributor.include?(word) }
          git_year = [git_year || 0, year].max
        end
      end

      if git_year && git_year != c[:year]
        puts "  Updated #{c[:holder].split.first}'s copyright year: #{c[:year]} -> #{git_year}"
        updated_copyrights << { year: git_year, holder: c[:holder] }
        needs_update = true
      else
        updated_copyrights << c
      end
    end

    # Check if YOUR year needs updating
    your_existing = updated_copyrights.find { |c| YOUR_IDENTIFIERS.any? { |id| c[:holder].include?(id) } }
    if your_existing.nil?
      puts "  Adding your copyright"
      updated_copyrights << { year: your_year, holder: YOUR_COPYRIGHT }
      needs_update = true
    end

    if existing[:license] != LICENSE
      puts "  Fixing license: #{existing[:license]} -> #{LICENSE}"
      needs_update = true
    end

    if needs_update
      # REUSE-IgnoreStart
      header_lines = ["<!--\n"]
      updated_copyrights.each do |c|
        header_lines << "  SPDX-FileCopyrightText: #{c[:year]} #{c[:holder]}\n"
      end
      header_lines << "  SPDX-License-Identifier: #{LICENSE}\n"
      header_lines << "-->\n"
      # REUSE-IgnoreEnd

      remaining = lines[(existing[:end_line] + 1)..]
      File.write(filepath, header_lines.join + remaining.join)
      puts "Updated: #{filepath}"
    end
  else
    # No header - add one with YOUR copyright only
    # REUSE-IgnoreStart
    header = "<!--\n  SPDX-FileCopyrightText: #{your_year} #{YOUR_COPYRIGHT}\n  SPDX-License-Identifier: #{LICENSE}\n-->\n"
    # REUSE-IgnoreEnd

    File.write(filepath, header + content)
    puts "Added header: #{filepath}"
  end
end