ratatui_ruby-devtools 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -1
  3. data/AGENTS.md +1 -1
  4. data/CHANGELOG.md +53 -7
  5. data/README.md +49 -21
  6. data/Rakefile +1 -1
  7. data/bin/setup +20 -6
  8. data/bin/setup.ps1 +74 -0
  9. data/doc/contributors/documentation_style.md +3 -4
  10. data/exe/scaffold +8 -342
  11. data/lib/ratatui_ruby/devtools/scaffold/scaffold_config.rb +67 -0
  12. data/lib/ratatui_ruby/devtools/scaffold/template_renderer.rb +282 -0
  13. data/lib/ratatui_ruby/devtools/task_scope.rb +24 -0
  14. data/lib/ratatui_ruby/devtools/tasks/autodoc_tasks.rb +31 -0
  15. data/lib/ratatui_ruby/devtools/tasks/bump/bump_workflow.rb +71 -0
  16. data/lib/ratatui_ruby/devtools/tasks/bump/changelog.rb +60 -3
  17. data/lib/ratatui_ruby/devtools/tasks/bump/github_project.rb +26 -0
  18. data/lib/ratatui_ruby/devtools/tasks/bump/history.rb +3 -3
  19. data/lib/ratatui_ruby/devtools/tasks/bump/links.rb +39 -24
  20. data/lib/ratatui_ruby/devtools/tasks/bump/manifest.rb +9 -17
  21. data/lib/ratatui_ruby/devtools/tasks/bump/no_crate.rb +19 -0
  22. data/lib/ratatui_ruby/devtools/tasks/bump/no_links.rb +22 -0
  23. data/lib/ratatui_ruby/devtools/tasks/bump/patch_release.rb +25 -0
  24. data/lib/ratatui_ruby/devtools/tasks/bump/release_branch.rb +17 -0
  25. data/lib/ratatui_ruby/devtools/tasks/bump/release_from_trunk.rb +48 -0
  26. data/lib/ratatui_ruby/devtools/tasks/bump/repository.rb +143 -0
  27. data/lib/ratatui_ruby/devtools/tasks/bump/ruby_gem.rb +65 -59
  28. data/lib/ratatui_ruby/devtools/tasks/bump/rust_crate.rb +42 -0
  29. data/lib/ratatui_ruby/devtools/tasks/bump/sem_ver.rb +127 -10
  30. data/lib/ratatui_ruby/devtools/tasks/bump/source_hut_project.rb +26 -0
  31. data/lib/ratatui_ruby/devtools/tasks/bump/unbranched_release.rb +20 -0
  32. data/lib/ratatui_ruby/devtools/tasks/bump/unreleased_section.rb +19 -1
  33. data/lib/ratatui_ruby/devtools/tasks/bump_tasks.rb +47 -0
  34. data/lib/ratatui_ruby/devtools/tasks/cargo_tasks.rb +57 -0
  35. data/lib/ratatui_ruby/devtools/tasks/doc/documentation.rb +59 -0
  36. data/lib/ratatui_ruby/devtools/tasks/doc/link/file_url.rb +30 -0
  37. data/lib/ratatui_ruby/devtools/tasks/doc/link/relative_path.rb +61 -0
  38. data/lib/ratatui_ruby/devtools/tasks/doc/link/web_url.rb +55 -0
  39. data/lib/ratatui_ruby/devtools/tasks/doc/link.rb +52 -0
  40. data/lib/ratatui_ruby/devtools/tasks/doc/link_audit.rb +116 -0
  41. data/lib/ratatui_ruby/devtools/tasks/doc/problem.rb +40 -0
  42. data/lib/ratatui_ruby/devtools/tasks/doc/source_file.rb +93 -0
  43. data/lib/ratatui_ruby/devtools/tasks/doc_tasks.rb +937 -0
  44. data/lib/ratatui_ruby/devtools/tasks/license/blame.rb +72 -0
  45. data/lib/ratatui_ruby/devtools/tasks/license/changed_files.rb +34 -0
  46. data/lib/ratatui_ruby/devtools/tasks/license/contribution.rb +12 -0
  47. data/lib/ratatui_ruby/devtools/tasks/license/copyright.rb +47 -0
  48. data/lib/ratatui_ruby/devtools/tasks/license/directory_licensing.rb +42 -0
  49. data/lib/ratatui_ruby/devtools/tasks/license/existing_header.rb +21 -0
  50. data/lib/ratatui_ruby/devtools/tasks/license/header_revision.rb +45 -0
  51. data/lib/ratatui_ruby/devtools/tasks/license/licensed_file.rb +39 -0
  52. data/lib/ratatui_ruby/devtools/tasks/license/licensing.rb +35 -0
  53. data/lib/ratatui_ruby/devtools/tasks/license/markdown_document.rb +23 -0
  54. data/lib/ratatui_ruby/devtools/tasks/license/markdown_files.rb +31 -0
  55. data/lib/ratatui_ruby/devtools/tasks/license/markdown_header_syntax.rb +104 -0
  56. data/lib/ratatui_ruby/devtools/tasks/license/markdown_snippet_syntax.rb +159 -0
  57. data/lib/ratatui_ruby/devtools/tasks/license/missing_header.rb +18 -0
  58. data/lib/ratatui_ruby/devtools/tasks/license/rbs_files.rb +27 -0
  59. data/lib/ratatui_ruby/devtools/tasks/license/rbs_header_syntax.rb +31 -0
  60. data/lib/ratatui_ruby/devtools/tasks/license/rdoc_comment.rb +51 -0
  61. data/lib/ratatui_ruby/devtools/tasks/license/rdoc_examples.rb +41 -0
  62. data/lib/ratatui_ruby/devtools/tasks/license/rdoc_snippet_syntax.rb +119 -0
  63. data/lib/ratatui_ruby/devtools/tasks/license/renewal.rb +17 -0
  64. data/lib/ratatui_ruby/devtools/tasks/license/reuse_annotated_file.rb +12 -0
  65. data/lib/ratatui_ruby/devtools/tasks/license/reuse_tool.rb +34 -0
  66. data/lib/ratatui_ruby/devtools/tasks/license/ruby_comment_header.rb +49 -0
  67. data/lib/ratatui_ruby/devtools/tasks/license/ruby_files.rb +36 -0
  68. data/lib/ratatui_ruby/devtools/tasks/license/ruby_header_syntax.rb +39 -0
  69. data/lib/ratatui_ruby/devtools/tasks/license/rust_files.rb +27 -0
  70. data/lib/ratatui_ruby/devtools/tasks/license/rust_header_syntax.rb +59 -0
  71. data/lib/ratatui_ruby/devtools/tasks/license/snippet.rb +44 -0
  72. data/lib/ratatui_ruby/devtools/tasks/license/snippet_file.rb +29 -0
  73. data/lib/ratatui_ruby/devtools/tasks/license/snippet_opening.rb +39 -0
  74. data/lib/ratatui_ruby/devtools/tasks/license/snippet_revision.rb +75 -0
  75. data/lib/ratatui_ruby/devtools/tasks/license/spdx_header.rb +42 -0
  76. data/lib/ratatui_ruby/devtools/tasks/license/unknown_file.rb +12 -0
  77. data/lib/ratatui_ruby/devtools/tasks/license/unlicensed_files.rb +69 -0
  78. data/lib/ratatui_ruby/devtools/tasks/license/wrapped_snippet.rb +30 -0
  79. data/lib/ratatui_ruby/devtools/tasks/license_tasks.rb +71 -0
  80. data/lib/ratatui_ruby/devtools/tasks/lint/gem_lockfile.rb +29 -0
  81. data/lib/ratatui_ruby/devtools/tasks/lint/supported_rubies.rb +18 -0
  82. data/lib/ratatui_ruby/devtools/tasks/lint_tasks.rb +100 -0
  83. data/lib/ratatui_ruby/devtools/tasks/release/platform_gem.rb +21 -12
  84. data/lib/ratatui_ruby/devtools/tasks/release/versioned_binary.rb +1 -1
  85. data/lib/ratatui_ruby/devtools/tasks/release_tasks.rb +80 -0
  86. data/lib/ratatui_ruby/devtools/tasks/reuse_tasks.rb +39 -0
  87. data/lib/ratatui_ruby/devtools/tasks/{test.rake → test_tasks.rb} +13 -4
  88. data/lib/ratatui_ruby/devtools/tasks/website/hosted_source_tree.rb +24 -0
  89. data/lib/ratatui_ruby/devtools/tasks/website/index_page.rb +1 -4
  90. data/lib/ratatui_ruby/devtools/tasks/website/source_tree.rb +63 -0
  91. data/lib/ratatui_ruby/devtools/tasks/website/versioned_documentation.rb +14 -13
  92. data/lib/ratatui_ruby/devtools/tasks/website_tasks.rb +49 -0
  93. data/lib/ratatui_ruby/devtools/templates/.github/workflows/build-gems.yml.erb +7 -1
  94. data/lib/ratatui_ruby/devtools/templates/.github/workflows/ci.yml.erb +2 -2
  95. data/lib/ratatui_ruby/devtools/templates/.gitignore.erb +2 -1
  96. data/lib/ratatui_ruby/devtools/templates/.rubocop.yml.erb +1 -1
  97. data/lib/ratatui_ruby/devtools/templates/AGENTS.md.erb +1 -1
  98. data/lib/ratatui_ruby/devtools/templates/CHANGELOG.md.erb +3 -1
  99. data/lib/ratatui_ruby/devtools/templates/CONTRIBUTING.md.erb +3 -3
  100. data/lib/ratatui_ruby/devtools/templates/Gemfile.erb +2 -3
  101. data/lib/ratatui_ruby/devtools/templates/README.md.erb +7 -7
  102. data/lib/ratatui_ruby/devtools/templates/REUSE.toml.erb +1 -1
  103. data/lib/ratatui_ruby/devtools/templates/bin/setup.erb +5 -1
  104. data/lib/ratatui_ruby/devtools/templates/bin/setup.ps1.erb +5 -1
  105. data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_architecture.md.erb +2 -2
  106. data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_testing.md.erb +3 -3
  107. data/lib/ratatui_ruby/devtools/templates/doc/getting_started/quickstart.md.erb +4 -4
  108. data/lib/ratatui_ruby/devtools/templates/ext/Cargo.toml.erb +1 -1
  109. data/lib/ratatui_ruby/devtools/templates/gemspec.erb +1 -1
  110. data/lib/ratatui_ruby/devtools/templates/mise.toml.erb +3 -3
  111. data/lib/ratatui_ruby/devtools/version.rb +1 -1
  112. data/lib/ratatui_ruby/devtools.rb +54 -71
  113. data/mise.toml +2 -2
  114. data/sig/ratatui_ruby/devtools.rbs +16 -1
  115. metadata +93 -34
  116. data/.builds/ruby-4.0.yml +0 -38
  117. data/lib/ratatui_ruby/devtools/tasks/autodoc.rake +0 -21
  118. data/lib/ratatui_ruby/devtools/tasks/bump.rake +0 -80
  119. data/lib/ratatui_ruby/devtools/tasks/cargo.rake +0 -47
  120. data/lib/ratatui_ruby/devtools/tasks/doc.rake +0 -887
  121. data/lib/ratatui_ruby/devtools/tasks/license/headers_md.rb +0 -276
  122. data/lib/ratatui_ruby/devtools/tasks/license/headers_rb.rb +0 -236
  123. data/lib/ratatui_ruby/devtools/tasks/license/license_utils.rb +0 -143
  124. data/lib/ratatui_ruby/devtools/tasks/license/snippets_md.rb +0 -353
  125. data/lib/ratatui_ruby/devtools/tasks/license/snippets_rdoc.rb +0 -186
  126. data/lib/ratatui_ruby/devtools/tasks/license.rake +0 -91
  127. data/lib/ratatui_ruby/devtools/tasks/lint.rake +0 -80
  128. data/lib/ratatui_ruby/devtools/tasks/release.rake +0 -87
  129. data/lib/ratatui_ruby/devtools/tasks/resources/rubies.yml +0 -7
  130. data/lib/ratatui_ruby/devtools/tasks/reuse.rake +0 -104
  131. data/lib/ratatui_ruby/devtools/tasks/website.rake +0 -37
  132. data/lib/ratatui_ruby/devtools/templates/.builds/ruby.yml.erb +0 -47
  133. data/lib/ratatui_ruby/devtools/templates/tasks/resources/index.html.erb +0 -45
  134. data/lib/ratatui_ruby/devtools/templates/tasks/resources/rubies.yml.erb +0 -8
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "licensed_file"
9
+ require_relative "rbs_header_syntax"
10
+
11
+ class RbsFiles < Data.define(:files) # :nodoc:
12
+ def self.changed(repository, licensing)
13
+ new(files: LicensedFile.changed(RbsHeaderSyntax.new(licensing), repository:))
14
+ end
15
+
16
+ def label
17
+ "RBS"
18
+ end
19
+
20
+ def count
21
+ files.count
22
+ end
23
+
24
+ def license
25
+ files.flat_map(&:annotate)
26
+ end
27
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "directory_licensing"
9
+ require_relative "ruby_comment_header"
10
+
11
+ # RBS has no magic comments, so its header opens the file.
12
+ class RbsHeaderSyntax # :nodoc:
13
+ include DirectoryLicensing
14
+ include RubyCommentHeader
15
+
16
+ def extensions
17
+ %w[rbs]
18
+ end
19
+
20
+ def directories
21
+ %w[sig]
22
+ end
23
+
24
+ def added(header, lines)
25
+ "#{block(header)}\n#{lines.drop_while { |line| line.strip.empty? }.join}"
26
+ end
27
+
28
+ def replaced(header, lines, end_line)
29
+ "#{block(header)}\n#{after_header(lines, end_line).join}"
30
+ end
31
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require "rdoc"
9
+ require_relative "rdoc_examples"
10
+ require_relative "snippet"
11
+
12
+ # A run of comment lines, read the way RDoc reads it. Indentation alone
13
+ # doesn't make an example: a list item's continuation and a call-seq are
14
+ # indented too, and blank lines after an example aren't part of it.
15
+ class RdocComment < Data.define(:first, :prefix, :lines) # :nodoc:
16
+ def self.all_in(file_lines)
17
+ file_lines.each_with_index
18
+ .select { |line, _| prefix_of(line) }
19
+ .chunk_while { |(line, index), (other, other_index)| other_index == index + 1 && prefix_of(other) == prefix_of(line) }
20
+ .map { |run| new(first: run.first.last, prefix: prefix_of(run.first.first), lines: run.map(&:first)) }
21
+ end
22
+
23
+ def self.prefix_of(line)
24
+ line[/\A(\s*)#/, 1]
25
+ end
26
+
27
+ # RDoc's markup carries no line numbers, and its own steps drop lines on
28
+ # the way, so each example is found again by all of its lines.
29
+ def examples
30
+ cursor = 0
31
+ RDoc::Markup.parse(markup).accept(RdocExamples.new).filter_map do |verbatim|
32
+ code = verbatim.text.lines.map(&:strip)
33
+ start = cursor.upto(lines.length - code.length).find { |index| code_at?(index, code) }
34
+ next unless start
35
+
36
+ cursor = start + code.length
37
+ Snippet.around(first + start, first + cursor - 1, prefix:)
38
+ end
39
+ end
40
+
41
+ # An :include: directive names a file RDoc would read in, but it holds no
42
+ # example from this one.
43
+ private def markup
44
+ text, = RDoc::Comment.parse(lines.map { |line| line.delete_prefix(prefix) }.join, nil, first, :ruby) { "" }
45
+ text
46
+ end
47
+
48
+ private def code_at?(index, code)
49
+ lines[index, code.length].map { |line| line.sub(/\A\s*#+/, "").strip } == code
50
+ end
51
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require "rdoc"
9
+
10
+ # The code blocks of an RDoc document, wherever they sit, as RDoc's own
11
+ # table of contents collects its headings.
12
+ class RdocExamples < RDoc::Markup::Formatter # :nodoc:
13
+ def start_accepting
14
+ @examples = []
15
+ end
16
+
17
+ def end_accepting
18
+ @examples
19
+ end
20
+
21
+ def accept_verbatim(verbatim)
22
+ @examples << verbatim
23
+ end
24
+
25
+ def ignore(*)
26
+ end
27
+
28
+ alias accept_block_quote ignore
29
+ alias accept_raw ignore
30
+ alias accept_rule ignore
31
+ alias accept_blank_line ignore
32
+ alias accept_hard_break ignore
33
+ alias accept_heading ignore
34
+ alias accept_paragraph ignore
35
+ alias accept_indented_paragraph ignore
36
+ alias accept_list_start ignore
37
+ alias accept_list_end ignore
38
+ alias accept_list_item_start ignore
39
+ alias accept_list_item_end ignore
40
+ alias accept_table ignore
41
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "rdoc_comment"
9
+ require_relative "snippet"
10
+ require_relative "snippet_opening"
11
+ require_relative "wrapped_snippet"
12
+
13
+ # The SPDX wrapper sits between #-- and #++ so RDoc leaves it out of the
14
+ # docs.
15
+ class RdocSnippetSyntax # :nodoc:
16
+ LICENSE = "MIT-0"
17
+
18
+ def extensions
19
+ %w[rb]
20
+ end
21
+
22
+ def tracked_patterns
23
+ ["*.rb"]
24
+ end
25
+
26
+ # Existing wrappers with their copyright notices under the snippet tag,
27
+ # without the blank lines that once followed their code inside them, and
28
+ # only around text RDoc shows as code.
29
+ def cleaned(lines)
30
+ in_tags = false
31
+ tagged = lines.map do |line|
32
+ in_tags = true if line.match?(/^\s*# SPDX-SnippetBegin\s*$/)
33
+ cleaned_line = in_tags ? Snippet.tag(line) : line
34
+ in_tags = false if line.match?(/^\s*#\+\+\s*$/)
35
+ cleaned_line
36
+ end
37
+ without_wrappers_around_prose(blanks_after_wrappers(tagged))
38
+ end
39
+
40
+ def license
41
+ LICENSE
42
+ end
43
+
44
+ # A wrapper marks its own snippet, whatever RDoc makes of the code inside
45
+ # it. RDoc finds the examples that have no wrapper yet.
46
+ def snippets_in(lines)
47
+ wrapped = wrapped_in(lines)
48
+ bare = examples(lines).reject { |example| wrapped.any? { |snippet| snippet.covers?(example) } }
49
+ (wrapped + bare).sort_by(&:first)
50
+ end
51
+
52
+ def opening(snippet, copyrights)
53
+ prefix = snippet.prefix
54
+ # REUSE-IgnoreStart
55
+ [
56
+ "#{prefix}#--\n",
57
+ "#{prefix}# SPDX-SnippetBegin\n",
58
+ *copyrights.map { |copyright| "#{prefix}# #{Snippet::COPYRIGHT_TAG}: #{copyright}\n" },
59
+ "#{prefix}# SPDX-License-Identifier: #{LICENSE}\n",
60
+ "#{prefix}#++\n",
61
+ ]
62
+ # REUSE-IgnoreEnd
63
+ end
64
+
65
+ def closing(snippet)
66
+ prefix = snippet.prefix
67
+ # REUSE-IgnoreStart
68
+ [
69
+ "#{prefix}#--\n",
70
+ "#{prefix}# SPDX-SnippetEnd\n",
71
+ "#{prefix}#++\n",
72
+ ]
73
+ # REUSE-IgnoreEnd
74
+ end
75
+
76
+ private def examples(lines)
77
+ RdocComment.all_in(lines).flat_map(&:examples)
78
+ end
79
+
80
+ # The code between each SnippetBegin's #++ and its SnippetEnd's #--.
81
+ private def wrapped_in(lines)
82
+ lines.each_index.filter_map do |index|
83
+ next unless lines[index].match?(/^\s*# SPDX-SnippetBegin\s*$/)
84
+
85
+ prefix = lines[index][/\A\s*/]
86
+ opened = (index + 1).upto(lines.length - 1).find { |line| lines[line].match?(/^#{Regexp.escape(prefix)}#\+\+\s*$/) }
87
+ ended = opened && (opened + 1).upto(lines.length - 1).find { |line| lines[line].match?(/^\s*# SPDX-SnippetEnd\s*$/) }
88
+ next unless ended && ended - 2 > opened
89
+
90
+ snippet = Snippet.around(opened + 1, ended - 2, prefix:)
91
+ WrappedSnippet.new(snippet:, opening: SnippetOpening.in(lines, index - 1, opened))
92
+ end
93
+ end
94
+
95
+ # Wrappers once went around every indented comment, list items' text
96
+ # included. A wrapper whose lines RDoc shows as prose comes off.
97
+ private def without_wrappers_around_prose(lines)
98
+ code = examples(lines).map(&:first)
99
+ prose = wrapped_in(lines).reject { |wrapped| code.include?(wrapped.first) }
100
+ prose.reverse_each.with_object(lines.dup) do |wrapped, unwrapped|
101
+ ended = wrapped.snippet.last + 1
102
+ unwrapped.slice!(ended..(ended + 2))
103
+ unwrapped.slice!(wrapped.opening.span)
104
+ end
105
+ end
106
+
107
+ # Blank comment lines after an example aren't part of it, as RDoc reads it.
108
+ private def blanks_after_wrappers(lines)
109
+ lines = lines.dup
110
+ lines.each_index do |index|
111
+ next unless lines[index].match?(/^\s*#--\s*$/) && lines[index + 1]&.match?(/^\s*# SPDX-SnippetEnd\s*$/)
112
+
113
+ first_blank = index
114
+ first_blank -= 1 while first_blank.positive? && lines[first_blank - 1].match?(/^\s*#\s*$/)
115
+ lines.insert(first_blank + 3, *lines.slice!(first_blank...index)) if first_blank < index
116
+ end
117
+ lines
118
+ end
119
+ end
@@ -0,0 +1,17 @@
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
+ # Something brought up to date, with a notice for each change made to it.
9
+ class Renewal < Data.define(:value, :notices) # :nodoc:
10
+ def self.unchanged(value)
11
+ new(value:, notices: [])
12
+ end
13
+
14
+ def changed?
15
+ notices.any?
16
+ end
17
+ end
@@ -0,0 +1,12 @@
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
+ class ReuseAnnotatedFile < Data.define(:path, :license, :copyright, :tool) # :nodoc:
9
+ def annotate
10
+ ["Annotating #{path} with #{license}", *tool.annotate(path, license:, copyright:)]
11
+ end
12
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require "open3"
9
+
10
+ class ReuseTool # :nodoc:
11
+ # The lint report lists files with no copyright and licensing information
12
+ # under a heading of their own, up to the next heading.
13
+ def unlicensed_paths
14
+ listed = false
15
+ `reuse lint 2>&1`.lines.filter_map do |line|
16
+ listed = true if line.include?("no copyright and licensing")
17
+ listed = false if line.start_with?("# ") && !line.include?("copyright")
18
+ # On Windows the tool reports paths with backslashes.
19
+ line[/^\* (.+)/, 1]&.tr("\\", "/") if listed
20
+ end
21
+ end
22
+
23
+ # Lockfiles and plain text have no comments to hold a header, so they get
24
+ # a .license file beside them.
25
+ def annotate(path, license:, copyright:)
26
+ output, status = Open3.capture2e(
27
+ "reuse", "annotate", "--license", license, "--copyright", copyright,
28
+ "--fallback-dot-license", "--skip-existing", path
29
+ )
30
+ raise "reuse annotate failed for #{path}:\n#{output}" unless status.success?
31
+
32
+ output.lines(chomp: true)
33
+ end
34
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "copyright"
9
+ require_relative "existing_header"
10
+ require_relative "missing_header"
11
+ require_relative "spdx_header"
12
+
13
+ # Ruby and RBS share comments, so their headers sit between the same #-- and
14
+ # #++ that keep a Ruby header out of the docs.
15
+ module RubyCommentHeader # :nodoc:
16
+ # REUSE-IgnoreStart
17
+ COPYRIGHT = /^#\s*SPDX-FileCopyrightText:\s*(\d{4})\s+(.+)$/
18
+ LICENSE = /^#\s*SPDX-License-Identifier:\s*(.+)$/
19
+ # REUSE-IgnoreEnd
20
+
21
+ # The header is in the comments the file opens with. Without its #++, it
22
+ # ends at its last tag.
23
+ def header_in(lines)
24
+ copyrights = []
25
+ license = nil
26
+ last_tag = nil
27
+ lines.each_with_index do |line, index|
28
+ case line
29
+ when COPYRIGHT
30
+ copyrights << Copyright.new(year: $1.to_i, holder: $2.strip)
31
+ last_tag = index
32
+ when LICENSE
33
+ license = $1.strip
34
+ last_tag = index
35
+ when /^#\+\+\s*$/
36
+ return ExistingHeader.new(header: SpdxHeader.new(copyrights:, license:), end_line: index) if last_tag
37
+ when /^\s*[^#\s]/
38
+ break
39
+ end
40
+ end
41
+ return MissingHeader.new unless last_tag
42
+
43
+ ExistingHeader.new(header: SpdxHeader.new(copyrights:, license:), end_line: last_tag)
44
+ end
45
+
46
+ private def block(header)
47
+ "#--\n#{header.commented('#')}#++\n"
48
+ end
49
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "licensed_file"
9
+ require_relative "rdoc_snippet_syntax"
10
+ require_relative "ruby_header_syntax"
11
+ require_relative "snippet_file"
12
+
13
+ class RubyFiles < Data.define(:files) # :nodoc:
14
+ def self.changed(repository, licensing)
15
+ new(files: LicensedFile.changed(RubyHeaderSyntax.new(licensing), repository:))
16
+ end
17
+
18
+ def label
19
+ "Ruby"
20
+ end
21
+
22
+ def count
23
+ files.count
24
+ end
25
+
26
+ def license
27
+ files.flat_map(&:annotate) + library.flat_map(&:annotate)
28
+ end
29
+
30
+ # RDoc examples only appear in the documented library.
31
+ private def library
32
+ syntax = RdocSnippetSyntax.new
33
+ files.select { |file| file.path.start_with?("lib/") && file.path.end_with?(".rb") }
34
+ .map { |file| SnippetFile.new(path: file.path, syntax:, author: file.author) }
35
+ end
36
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "directory_licensing"
9
+ require_relative "ruby_comment_header"
10
+
11
+ class RubyHeaderSyntax # :nodoc:
12
+ include DirectoryLicensing
13
+ include RubyCommentHeader
14
+
15
+ FROZEN_STRING_LITERAL = "# frozen_string_literal: true\n"
16
+
17
+ def extensions
18
+ %w[rb]
19
+ end
20
+
21
+ def directories
22
+ %w[lib ext test examples tasks bin]
23
+ end
24
+
25
+ def added(header, lines)
26
+ frozen = frozen_string_literal_in(lines)
27
+ body = (frozen ? lines.drop(1) : lines).drop_while { |line| line.strip.empty? }
28
+ "#{frozen || FROZEN_STRING_LITERAL}\n#{block(header)}\n#{body.join}"
29
+ end
30
+
31
+ def replaced(header, lines, end_line)
32
+ frozen = frozen_string_literal_in(lines)
33
+ "#{frozen || FROZEN_STRING_LITERAL}\n#{block(header)}\n#{after_header(lines, end_line).join}"
34
+ end
35
+
36
+ private def frozen_string_literal_in(lines)
37
+ lines.first if lines.first&.include?("frozen_string_literal")
38
+ end
39
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "licensed_file"
9
+ require_relative "rust_header_syntax"
10
+
11
+ class RustFiles < Data.define(:files) # :nodoc:
12
+ def self.changed(repository, licensing)
13
+ new(files: LicensedFile.changed(RustHeaderSyntax.new(licensing), repository:))
14
+ end
15
+
16
+ def label
17
+ "Rust"
18
+ end
19
+
20
+ def count
21
+ files.count
22
+ end
23
+
24
+ def license
25
+ files.flat_map(&:annotate)
26
+ end
27
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "copyright"
9
+ require_relative "directory_licensing"
10
+ require_relative "existing_header"
11
+ require_relative "missing_header"
12
+ require_relative "spdx_header"
13
+
14
+ class RustHeaderSyntax # :nodoc:
15
+ include DirectoryLicensing
16
+
17
+ # REUSE-IgnoreStart
18
+ COPYRIGHT = %r{^//\s*SPDX-FileCopyrightText:\s*(\d{4})\s+(.+)$}
19
+ LICENSE = %r{^//\s*SPDX-License-Identifier:\s*(.+)$}
20
+ # REUSE-IgnoreEnd
21
+
22
+ def extensions
23
+ %w[rs]
24
+ end
25
+
26
+ def directories
27
+ %w[ext]
28
+ end
29
+
30
+ def header_in(lines)
31
+ copyrights = []
32
+ license = nil
33
+ last_tag = nil
34
+ lines.each_with_index do |line, index|
35
+ case line
36
+ when COPYRIGHT
37
+ copyrights << Copyright.new(year: $1.to_i, holder: $2.strip)
38
+ last_tag = index
39
+ when LICENSE
40
+ license = $1.strip
41
+ last_tag = index
42
+ else
43
+ next if line.start_with?("//")
44
+ break if license || !line.strip.empty?
45
+ end
46
+ end
47
+ return MissingHeader.new unless last_tag
48
+
49
+ ExistingHeader.new(header: SpdxHeader.new(copyrights:, license:), end_line: last_tag)
50
+ end
51
+
52
+ def added(header, lines)
53
+ "#{header.commented('//')}\n#{lines.join}"
54
+ end
55
+
56
+ def replaced(header, lines, end_line)
57
+ "#{header.commented('//')}\n#{after_header(lines, end_line).join}"
58
+ end
59
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ # A code example inside a file, wrapped from its first to its last line
9
+ # counting from 0, behind the given comment prefix. Its year comes from the
10
+ # code lines alone, which can sit inside markers the wrapper goes around.
11
+ class Snippet < Data.define(:first, :last, :prefix, :code) # :nodoc:
12
+ # REUSE 3.3 requires copyright notices inside a snippet to use the snippet
13
+ # tag. Earlier wrappers used the file tag.
14
+ # REUSE-IgnoreStart
15
+ COPYRIGHT_TAG = "SPDX-SnippetCopyrightText"
16
+ FILE_COPYRIGHT_TAG = "SPDX-FileCopyrightText"
17
+ # REUSE-IgnoreEnd
18
+
19
+ def self.around(first, last, prefix: "", code: [first, last])
20
+ new(first:, last:, prefix:, code:)
21
+ end
22
+
23
+ # A line of a wrapper's tags, using the snippet copyright tag.
24
+ def self.tag(line)
25
+ line.sub(FILE_COPYRIGHT_TAG, COPYRIGHT_TAG)
26
+ end
27
+
28
+ # The code lines as git blame counts them, from 1.
29
+ def blame_range
30
+ code.map { |line| line + 1 }
31
+ end
32
+
33
+ def lines_in(file_lines)
34
+ file_lines[first..last]
35
+ end
36
+
37
+ def bare?
38
+ true
39
+ end
40
+
41
+ def revise(revision)
42
+ revision.wrap(self)
43
+ end
44
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "../bump/repository"
9
+ require_relative "snippet_revision"
10
+
11
+ class SnippetFile < Data.define(:path, :syntax, :author) # :nodoc:
12
+ def self.tracked(paths, syntax, repository: Repository.new)
13
+ files = repository.tracked_files_for(paths, syntax)
14
+ return [] if files.empty?
15
+
16
+ author = repository.author
17
+ files.map { |path| new(path:, syntax:, author:) }
18
+ end
19
+
20
+ # Brings the file's snippets up to date and says what changed.
21
+ def annotate
22
+ # Binary mode keeps the file's own line endings, which text mode on
23
+ # Windows would turn into CRLF.
24
+ text = File.read(path, mode: "rb", encoding: "UTF-8")
25
+ revision = SnippetRevision.new(path:, text:, syntax:, author:).run
26
+ File.write(path, revision.text, mode: "wb") if revision.changed?
27
+ revision.messages
28
+ end
29
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "copyright"
9
+ require_relative "renewal"
10
+ require_relative "spdx_header"
11
+
12
+ # The tags that open a snippet already wrapped, from their first to their last
13
+ # line counting from 0, with the notices they hold.
14
+ class SnippetOpening < Data.define(:first, :last, :header) # :nodoc:
15
+ # REUSE-IgnoreStart
16
+ COPYRIGHT = /SPDX-(?:Snippet|File)CopyrightText:\s*(\d{4})\s+(.+)$/
17
+ LICENSE = /SPDX-License-Identifier:\s*(\S+)/
18
+ # REUSE-IgnoreEnd
19
+
20
+ def self.in(lines, first, last)
21
+ tags = lines[first..last]
22
+ copyrights = tags.grep(COPYRIGHT) { Copyright.new(year: $1.to_i, holder: $2.strip) }
23
+ license = tags.grep(LICENSE) { $1 }.last
24
+ new(first:, last:, header: SpdxHeader.new(copyrights:, license:))
25
+ end
26
+
27
+ def span
28
+ first..last
29
+ end
30
+
31
+ def copyrights
32
+ header.copyrights
33
+ end
34
+
35
+ def renewal(contributions, license)
36
+ renewal = header.renewal(contributions, license)
37
+ Renewal.new(value: with(header: renewal.value), notices: renewal.notices)
38
+ end
39
+ end