ratatui_ruby-devtools 0.4.0 → 0.4.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 57fc1bc3e22f7db96b7434687a5ba68ebd2e791af094ca8c7b0fa93046c0c8e0
4
- data.tar.gz: ce0771ddec4a189df1e9ec5de19a9bc2fad461b6265575380d1c15a8120e6939
3
+ metadata.gz: 7c9a5440c5c524c14e18b914b7d5666c97f0e65384aac9952e5b8051d4459b44
4
+ data.tar.gz: f13332c056f0fd2becec90b040fc4f57c170e692efded028f9d49390874711c0
5
5
  SHA512:
6
- metadata.gz: ee73b0da8bbe0f1ae17c266f7320a6795036e7c8e9a2b9c2c0ecb2f4c125a788a319ed475a76e759e7531805fdafda9372ec6dd20baed006dc95fe95b00fc8a4
7
- data.tar.gz: 14d72b6a59018d202eba6e175d4cf8716a4c42791ac759402a6367b23168fe6d6678e2199384cf34269b6d67899d7610ee19c0c8c73f6b5238b8506b28cebc72
6
+ metadata.gz: fec0e188fc6ff2f9386c571f942e4838d855212dbdc9d18763fc4aca146ea07f742cf1f9867c1e74d0024ab907bd4c4821d6a2d42233166b6cdf65b10943abc9
7
+ data.tar.gz: 5e81a6e80457bd82db93223473b0091b7746f162f6669c88a23df40c1efecd5e951818554840f7120317fec0edc2368f37b8b31142a6210ac09009b02153ce1c
data/CHANGELOG.md CHANGED
@@ -21,6 +21,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
21
21
 
22
22
  ### Removed
23
23
 
24
+ ## [0.4.1] - 2026-10-24
25
+
26
+ ### Added
27
+
28
+ ### Changed
29
+
30
+ ### Fixed
31
+
32
+ - **Year ranges in copyright notices were misread**: A notice with a range of years, such as `2016-2022 Jane Doe`, didn't count as a notice. A license header kept it but added a second notice for the same holder, and a snippet's renewed tags dropped it. A range now renews from its end, as in `2016-2024 Jane Doe`.
33
+ - **Snippet tasks relicensed quoted code**: `rake license:snippets:all` rewrapped a Markdown snippet under any license but MIT-0 as yours, and relicensed an RDoc one MIT-0, though a license like MIT requires keeping the quoted authors' notices. Such snippets now keep their notices and license.
34
+ - **Incomplete Markdown snippets lost their notices**: `rake license:snippets:md` rewrapped a Markdown snippet missing its license or copyright tag from git blame, dropping notices git can't see, such as code a contributor brought from elsewhere. It now completes the snippet's tags in place, as it already did for RDoc examples.
35
+ - **Stray comments after renewing Markdown snippets**: Renewing a Markdown snippet whose tags share one comment with `SPDX-SnippetBegin` left a stray `<!--` above the new wrapper. A snippet with a comment for each tag was rewrapped, leaving its old copyright comment behind. Both now renew in place.
36
+
37
+ ### Removed
38
+
24
39
  ## [0.4.0] - 2026-10-23
25
40
 
26
41
  ### Added
@@ -156,6 +171,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
156
171
  ### Removed
157
172
 
158
173
  [Unreleased]: https://github.com/setdef/ratatui_ruby-devtools/tree/HEAD
174
+ [0.4.1]: https://github.com/setdef/ratatui_ruby-devtools/releases/tag/v0.4.1
159
175
  [0.4.0]: https://github.com/setdef/ratatui_ruby-devtools/releases/tag/v0.4.0
160
176
  [0.3.0]: https://github.com/setdef/ratatui_ruby-devtools/releases/tag/v0.3.0
161
177
  [0.2.1]: https://github.com/setdef/ratatui_ruby-devtools/releases/tag/v0.2.1
@@ -8,7 +8,22 @@
8
8
  require "date"
9
9
  require_relative "renewal"
10
10
 
11
- class Copyright < Data.define(:year, :holder) # :nodoc:
11
+ # A notice's year is its latest. A range keeps the year it starts from, which
12
+ # is +since+.
13
+ class Copyright < Data.define(:year, :holder, :since) # :nodoc:
14
+ # Non-capturing, because the syntaxes read the holder from the group after
15
+ # it.
16
+ YEARS = /\d{4}(?:\s*-\s*\d{4})?/
17
+
18
+ def initialize(year:, holder:, since: nil)
19
+ super
20
+ end
21
+
22
+ def self.parse(years, holder)
23
+ since, year = years.scan(/\d{4}/).map(&:to_i).minmax
24
+ new(year:, holder: holder.strip, since: (since if since < year))
25
+ end
26
+
12
27
  def self.of(contribution)
13
28
  new(year: contribution.year, holder: contribution.author.to_s)
14
29
  end
@@ -42,6 +57,10 @@ class Copyright < Data.define(:year, :holder) # :nodoc:
42
57
  end
43
58
 
44
59
  def to_s
45
- "#{year} #{holder}"
60
+ if since
61
+ "#{since}-#{year} #{holder}"
62
+ else
63
+ "#{year} #{holder}"
64
+ end
46
65
  end
47
66
  end
@@ -0,0 +1,26 @@
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 "snippet"
9
+
10
+ # An HTML comment in a Markdown document, from the line it opens on to the
11
+ # line it closes on, counting from 0.
12
+ class MarkdownComment < Data.define(:first, :last, :text) # :nodoc:
13
+ def span
14
+ first..last
15
+ end
16
+
17
+ def holds_line?(index)
18
+ index.between?(first, last)
19
+ end
20
+
21
+ # SPDX tags that go with a wrapper's SnippetBegin, rather than opening or
22
+ # closing a wrapper of their own.
23
+ def tags?
24
+ text.include?("SPDX-") && !text.include?(Snippet::BEGIN_TAG) && !text.include?(Snippet::END_TAG)
25
+ end
26
+ end
@@ -5,6 +5,8 @@
5
5
  # SPDX-License-Identifier: AGPL-3.0-or-later
6
6
  #++
7
7
 
8
+ require_relative "markdown_comment"
9
+
8
10
  class MarkdownDocument < Data.define(:lines) # :nodoc:
9
11
  FENCE = /^(````*)\w*$/
10
12
 
@@ -12,6 +14,32 @@ class MarkdownDocument < Data.define(:lines) # :nodoc:
12
14
  lines[index].match?(FENCE)
13
15
  end
14
16
 
17
+ def comment_opens?(index)
18
+ lines.fetch(index, "").include?("<!--")
19
+ end
20
+
21
+ def comment_at(index)
22
+ last = comment_close(index)
23
+ MarkdownComment.new(first: index, last:, text: lines[index..last].join)
24
+ end
25
+
26
+ # The comment that line +index+ sits in, which can open on a line above it.
27
+ # A line in no comment stands alone, so prose after it stays out.
28
+ def comment_around(index)
29
+ first = index.downto(0).find { |earlier| comment_opens?(earlier) }
30
+ comment = first && comment_at(first)
31
+ if comment&.holds_line?(index)
32
+ comment
33
+ else
34
+ MarkdownComment.new(first: index, last: index, text: lines[index])
35
+ end
36
+ end
37
+
38
+ # The comment that opens on the line after +comment+ closes, if one does.
39
+ def comment_after(comment)
40
+ comment_at(comment.last + 1) if comment_opens?(comment.last + 1)
41
+ end
42
+
15
43
  # The line that closes the fence opening at +index+, when one does.
16
44
  def fence_end(index)
17
45
  marker = lines[index][FENCE, 1]
@@ -20,4 +48,9 @@ class MarkdownDocument < Data.define(:lines) # :nodoc:
20
48
  closing = /^#{Regexp.escape(marker)}$/
21
49
  (index + 1).upto(lines.length - 1).find { |later| lines[later].match?(closing) }
22
50
  end
51
+
52
+ # A comment that never closes runs to the end of the document.
53
+ private def comment_close(index)
54
+ index.upto(lines.length - 1).find { |later| lines[later].include?("-->") } || (lines.length - 1)
55
+ end
23
56
  end
@@ -15,7 +15,7 @@ require_relative "spdx_header"
15
15
  class MarkdownHeaderSyntax # :nodoc:
16
16
  LICENSE = "CC-BY-SA-4.0"
17
17
  # REUSE-IgnoreStart
18
- COPYRIGHT = /SPDX-FileCopyrightText:\s*(\d{4})\s+(.+)$/
18
+ COPYRIGHT = /SPDX-FileCopyrightText:\s*(#{Copyright::YEARS})\s+(.+)$/
19
19
  LICENSE_LINE = /SPDX-License-Identifier:\s*(.+)$/
20
20
  # REUSE-IgnoreEnd
21
21
 
@@ -46,7 +46,7 @@ class MarkdownHeaderSyntax # :nodoc:
46
46
  return MissingHeader.new unless closing
47
47
 
48
48
  comment = lines[..closing]
49
- copyrights = comment.grep(COPYRIGHT) { Copyright.new(year: $1.to_i, holder: $2.strip) }
49
+ copyrights = comment.grep(COPYRIGHT) { Copyright.parse($1, $2) }
50
50
  license = comment.grep(LICENSE_LINE) { $1.strip }.last
51
51
  return MissingHeader.new unless copyrights.any? || license
52
52
 
@@ -29,31 +29,37 @@ class MarkdownSnippetSyntax # :nodoc:
29
29
  LICENSE
30
30
  end
31
31
 
32
- # Without the wrappers that don't license their code MIT-0, so the code they
33
- # held can be wrapped again, and with the copyright notices of the rest
34
- # under the snippet tag.
35
- def cleaned(lines)
36
- _, removals = scan(lines, first_pass: true)
37
- lines = lines.dup
38
- removals.sort_by { |first, _| -first }.each do |first, last|
39
- lines.delete_at(last)
40
- (opening_end(lines, first) - first + 1).times { lines.delete_at(first) }
41
- end
42
- lines.each_index.select { |index| lines[index].include?("SPDX-SnippetBegin") }.each do |first|
43
- first.upto(opening_end(lines, first)) { |index| lines[index] = Snippet.tag(lines[index]) }
44
- end
45
- lines
46
- end
47
-
32
+ # A wrapper with no end leaves the rest of the document alone.
48
33
  def snippets_in(lines)
49
- regions, = scan(lines, first_pass: false)
50
- regions
34
+ document = MarkdownDocument.new(lines)
35
+ snippets = []
36
+ index = 0
37
+ while index < lines.length
38
+ if lines[index].include?(Snippet::BEGIN_TAG)
39
+ finish = index.upto(lines.length - 1).find { |later| lines[later].include?(Snippet::END_TAG) }
40
+ return snippets unless finish
41
+
42
+ closing = document.comment_around(finish).span
43
+ snippets.concat(wrapped_at(lines, document, document.comment_around(index).first, closing))
44
+ index = closing.last + 1
45
+ next
46
+ end
47
+
48
+ region = sync_region(lines, document, index) || fence_region(document, index)
49
+ if region
50
+ snippets << region
51
+ index = region.last + 1
52
+ else
53
+ index += 1
54
+ end
55
+ end
56
+ snippets
51
57
  end
52
58
 
53
59
  def opening(_snippet, copyrights)
54
60
  # REUSE-IgnoreStart
55
61
  [
56
- "<!-- SPDX-SnippetBegin -->\n",
62
+ "<!-- #{Snippet::BEGIN_TAG} -->\n",
57
63
  "<!--\n",
58
64
  *copyrights.map { |copyright| " #{Snippet::COPYRIGHT_TAG}: #{copyright}\n" },
59
65
  " SPDX-License-Identifier: #{LICENSE}\n",
@@ -63,64 +69,26 @@ class MarkdownSnippetSyntax # :nodoc:
63
69
  end
64
70
 
65
71
  def closing(_snippet)
66
- # REUSE-IgnoreStart
67
- ["<!-- SPDX-SnippetEnd -->\n"]
68
- # REUSE-IgnoreEnd
72
+ ["<!-- #{Snippet::END_TAG} -->\n"]
69
73
  end
70
74
 
71
- # The last line of the opening wrapper at +first+. A SnippetBegin comment
72
- # that closes on its own line is followed by the comment holding the SPDX
73
- # tags, which goes with it.
74
- private def opening_end(lines, first)
75
- start = tags_follow?(lines, first) ? first + 1 : first
76
- start.upto(lines.length - 1).find { |index| lines[index].include?("-->") } || (lines.length - 1)
77
- end
78
-
79
- private def tags_follow?(lines, first)
80
- lines[first].include?("-->") && lines[first + 1]&.include?("<!--")
81
- end
82
-
83
- # A wrapper from its SnippetBegin to its SnippetEnd, with the code between
84
- # its opening tags and its end.
85
- private def wrapped_region(lines, first, finish)
86
- opening = SnippetOpening.in(lines, first, opening_end(lines, first))
87
- return if opening.last + 1 > finish - 1
88
-
89
- WrappedSnippet.new(snippet: Snippet.around(first, finish, code: [opening.last + 1, finish - 1]), opening:)
75
+ # The last line of the opening at +first+: the comment holding its
76
+ # SnippetBegin, then each comment after it holding tags, whether one holds
77
+ # them all or each tag has its own.
78
+ private def opening_end(document, first)
79
+ comment = document.comment_at(first)
80
+ while (tags = document.comment_after(comment))&.tags?
81
+ comment = tags
82
+ end
83
+ comment.last
90
84
  end
91
85
 
92
- # The first pass leaves MIT-0 wrappers alone and marks the rest for
93
- # removal. The second, after removal, reports the wrappers left with their
94
- # openings.
95
- private def scan(lines, first_pass:)
96
- document = MarkdownDocument.new(lines)
97
- regions = []
98
- removals = []
99
- index = 0
100
- while index < lines.length
101
- if lines[index].include?("SPDX-SnippetBegin")
102
- finish = index.upto(lines.length - 1).find { |later| lines[later].include?("SPDX-SnippetEnd") }
103
- if !first_pass
104
- wrapped = wrapped_region(lines, index, finish) if finish
105
- regions << wrapped if wrapped
106
- index = (finish || lines.length) + 1
107
- next
108
- elsif finish
109
- removals << [index, finish] unless licensed?(lines, index)
110
- index = finish + 1
111
- next
112
- end
113
- end
114
-
115
- region = sync_region(lines, document, index) || fence_region(document, index)
116
- if region
117
- regions << region
118
- index = region.last + 1
119
- else
120
- index += 1
121
- end
122
- end
123
- [regions, removals]
86
+ # The wrapper opening at +first+ and closing on the lines of +closing+. One
87
+ # around nothing has no code to credit, so it stays as it is.
88
+ private def wrapped_at(lines, document, first, closing)
89
+ opening = SnippetOpening.in(lines, first, opening_end(document, first))
90
+ snippet = Snippet.around(opening.last + 1, closing.first - 1)
91
+ snippet.empty? ? [] : [WrappedSnippet.found(snippet:, opening:, closing:, license: LICENSE)]
124
92
  end
125
93
 
126
94
  private def sync_region(lines, document, start)
@@ -146,14 +114,4 @@ class MarkdownSnippetSyntax # :nodoc:
146
114
  finish = document.fence_end(start)
147
115
  Snippet.around(start, finish) if finish
148
116
  end
149
-
150
- # A wrapper that already credits someone under MIT-0 stays, whoever it
151
- # credits.
152
- private def licensed?(lines, begin_line)
153
- tags = lines[(begin_line + 1)..].take_while { |line| !line.include?("-->") }
154
- # REUSE-IgnoreStart
155
- tags.any? { |line| line.include?(Snippet::COPYRIGHT_TAG) || line.include?(Snippet::FILE_COPYRIGHT_TAG) } &&
156
- tags.any? { |line| line.include?(LICENSE) && line.include?("SPDX-License-Identifier") }
157
- # REUSE-IgnoreEnd
158
- end
159
117
  end
@@ -0,0 +1,26 @@
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
+ # Code quoted from elsewhere under its own license, such as MIT, which
9
+ # requires keeping its notices as they are, whatever the code looks like.
10
+ class QuotedSnippet < Data.define(:snippet, :opening) # :nodoc:
11
+ def first
12
+ snippet.first
13
+ end
14
+
15
+ def covers?(other)
16
+ snippet.covers?(other)
17
+ end
18
+
19
+ def around_prose(_contents)
20
+ self
21
+ end
22
+
23
+ def revise(revision)
24
+ revision.retag(opening)
25
+ end
26
+ end
@@ -23,30 +23,16 @@ class RdocSnippetSyntax # :nodoc:
23
23
  ["*.rb"]
24
24
  end
25
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
26
  def license
41
27
  LICENSE
42
28
  end
43
29
 
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.
30
+ # RDoc finds the examples, and the ones no wrapper covers are bare.
46
31
  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)
32
+ examples = examples(lines)
33
+ wrappers = wrapped_in(lines)
34
+ bare = examples.reject { |example| wrappers.any? { |wrapper| wrapper.covers?(example) } }
35
+ (wrappers.map { |wrapper| as_read(wrapper, examples) } + bare).sort_by(&:first)
50
36
  end
51
37
 
52
38
  def opening(snippet, copyrights)
@@ -54,7 +40,7 @@ class RdocSnippetSyntax # :nodoc:
54
40
  # REUSE-IgnoreStart
55
41
  [
56
42
  "#{prefix}#--\n",
57
- "#{prefix}# SPDX-SnippetBegin\n",
43
+ "#{prefix}# #{Snippet::BEGIN_TAG}\n",
58
44
  *copyrights.map { |copyright| "#{prefix}# #{Snippet::COPYRIGHT_TAG}: #{copyright}\n" },
59
45
  "#{prefix}# SPDX-License-Identifier: #{LICENSE}\n",
60
46
  "#{prefix}#++\n",
@@ -64,56 +50,50 @@ class RdocSnippetSyntax # :nodoc:
64
50
 
65
51
  def closing(snippet)
66
52
  prefix = snippet.prefix
67
- # REUSE-IgnoreStart
68
53
  [
69
54
  "#{prefix}#--\n",
70
- "#{prefix}# SPDX-SnippetEnd\n",
55
+ "#{prefix}# #{Snippet::END_TAG}\n",
71
56
  "#{prefix}#++\n",
72
57
  ]
73
- # REUSE-IgnoreEnd
74
58
  end
75
59
 
76
60
  private def examples(lines)
77
61
  RdocComment.all_in(lines).flat_map(&:examples)
78
62
  end
79
63
 
80
- # The code between each SnippetBegin's #++ and its SnippetEnd's #--.
64
+ # The wrapper from each SnippetBegin to its SnippetEnd. Its code runs from
65
+ # the #++ to the last line before the SnippetEnd that isn't a blank comment,
66
+ # since RDoc ends an example there.
81
67
  private def wrapped_in(lines)
82
68
  lines.each_index.filter_map do |index|
83
- next unless lines[index].match?(/^\s*# SPDX-SnippetBegin\s*$/)
69
+ next unless lines[index].match?(/^\s*# #{Regexp.escape(Snippet::BEGIN_TAG)}\s*$/)
84
70
 
85
71
  prefix = lines[index][/\A\s*/]
86
72
  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))
73
+ ended = opened && (opened + 1).upto(lines.length - 1).find { |line| lines[line].match?(/^\s*# #{Regexp.escape(Snippet::END_TAG)}\s*$/) }
74
+ next unless ended
75
+
76
+ code_end = ended - 2
77
+ code_end -= 1 while code_end > opened && lines[code_end].match?(/^\s*#\s*$/)
78
+ next unless code_end > opened
79
+
80
+ WrappedSnippet.found(
81
+ snippet: Snippet.around(opened + 1, code_end, prefix:),
82
+ opening: SnippetOpening.in(lines, index - 1, opened),
83
+ closing: (ended - 1)..(ended + 1),
84
+ license: LICENSE
85
+ )
92
86
  end
93
87
  end
94
88
 
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
89
+ # The wrapper as RDoc reads it. Wrappers once went around every indented
90
+ # comment, list items' text included, so one that doesn't start at an
91
+ # example wraps prose.
92
+ private def as_read(wrapper, examples)
93
+ if examples.any? { |example| example.first == wrapper.first }
94
+ wrapper
95
+ else
96
+ wrapper.around_prose(examples.select { |example| wrapper.covers?(example) })
116
97
  end
117
- lines
118
98
  end
119
99
  end
@@ -14,7 +14,7 @@ require_relative "spdx_header"
14
14
  # #++ that keep a Ruby header out of the docs.
15
15
  module RubyCommentHeader # :nodoc:
16
16
  # REUSE-IgnoreStart
17
- COPYRIGHT = /^#\s*SPDX-FileCopyrightText:\s*(\d{4})\s+(.+)$/
17
+ COPYRIGHT = /^#\s*SPDX-FileCopyrightText:\s*(#{Copyright::YEARS})\s+(.+)$/
18
18
  LICENSE = /^#\s*SPDX-License-Identifier:\s*(.+)$/
19
19
  # REUSE-IgnoreEnd
20
20
 
@@ -27,7 +27,7 @@ module RubyCommentHeader # :nodoc:
27
27
  lines.each_with_index do |line, index|
28
28
  case line
29
29
  when COPYRIGHT
30
- copyrights << Copyright.new(year: $1.to_i, holder: $2.strip)
30
+ copyrights << Copyright.parse($1, $2)
31
31
  last_tag = index
32
32
  when LICENSE
33
33
  license = $1.strip
@@ -15,7 +15,7 @@ class RustHeaderSyntax # :nodoc:
15
15
  include DirectoryLicensing
16
16
 
17
17
  # REUSE-IgnoreStart
18
- COPYRIGHT = %r{^//\s*SPDX-FileCopyrightText:\s*(\d{4})\s+(.+)$}
18
+ COPYRIGHT = %r{^//\s*SPDX-FileCopyrightText:\s*(#{Copyright::YEARS})\s+(.+)$}
19
19
  LICENSE = %r{^//\s*SPDX-License-Identifier:\s*(.+)$}
20
20
  # REUSE-IgnoreEnd
21
21
 
@@ -34,7 +34,7 @@ class RustHeaderSyntax # :nodoc:
34
34
  lines.each_with_index do |line, index|
35
35
  case line
36
36
  when COPYRIGHT
37
- copyrights << Copyright.new(year: $1.to_i, holder: $2.strip)
37
+ copyrights << Copyright.parse($1, $2)
38
38
  last_tag = index
39
39
  when LICENSE
40
40
  license = $1.strip
@@ -9,9 +9,12 @@
9
9
  # counting from 0, behind the given comment prefix. Its year comes from the
10
10
  # code lines alone, which can sit inside markers the wrapper goes around.
11
11
  class Snippet < Data.define(:first, :last, :prefix, :code) # :nodoc:
12
+ # REUSE-IgnoreStart
13
+ BEGIN_TAG = "SPDX-SnippetBegin"
14
+ END_TAG = "SPDX-SnippetEnd"
15
+
12
16
  # REUSE 3.3 requires copyright notices inside a snippet to use the snippet
13
17
  # tag. Earlier wrappers used the file tag.
14
- # REUSE-IgnoreStart
15
18
  COPYRIGHT_TAG = "SPDX-SnippetCopyrightText"
16
19
  FILE_COPYRIGHT_TAG = "SPDX-FileCopyrightText"
17
20
  # REUSE-IgnoreEnd
@@ -30,12 +33,16 @@ class Snippet < Data.define(:first, :last, :prefix, :code) # :nodoc:
30
33
  code.map { |line| line + 1 }
31
34
  end
32
35
 
33
- def lines_in(file_lines)
34
- file_lines[first..last]
36
+ def empty?
37
+ last < first
35
38
  end
36
39
 
37
- def bare?
38
- true
40
+ def covers?(other)
41
+ other.first.between?(first, last)
42
+ end
43
+
44
+ def lines_in(file_lines)
45
+ file_lines[first..last]
39
46
  end
40
47
 
41
48
  def revise(revision)
@@ -13,17 +13,22 @@ require_relative "spdx_header"
13
13
  # line counting from 0, with the notices they hold.
14
14
  class SnippetOpening < Data.define(:first, :last, :header) # :nodoc:
15
15
  # REUSE-IgnoreStart
16
- COPYRIGHT = /SPDX-(?:Snippet|File)CopyrightText:\s*(\d{4})\s+(.+)$/
16
+ COPYRIGHT = /SPDX-(?:Snippet|File)CopyrightText:\s*(#{Copyright::YEARS})\s+(.+?)\s*(?:-->)?\s*$/
17
17
  LICENSE = /SPDX-License-Identifier:\s*(\S+)/
18
18
  # REUSE-IgnoreEnd
19
19
 
20
20
  def self.in(lines, first, last)
21
21
  tags = lines[first..last]
22
- copyrights = tags.grep(COPYRIGHT) { Copyright.new(year: $1.to_i, holder: $2.strip) }
22
+ copyrights = tags.grep(COPYRIGHT) { Copyright.parse($1, $2) }
23
23
  license = tags.grep(LICENSE) { $1 }.last
24
24
  new(first:, last:, header: SpdxHeader.new(copyrights:, license:))
25
25
  end
26
26
 
27
+ # Tags under a license other than +license+ quote someone else's code.
28
+ def quotes?(license)
29
+ !header.license.nil? && header.license != license
30
+ end
31
+
27
32
  def span
28
33
  first..last
29
34
  end
@@ -7,22 +7,22 @@
7
7
 
8
8
  require_relative "blame"
9
9
  require_relative "copyright"
10
+ require_relative "snippet"
10
11
 
11
12
  # One file's snippets brought up to date: each bare snippet asks to be
12
- # wrapped, and each wrapped one whose notices have moved on asks to be
13
- # reopened.
13
+ # wrapped, each wrapped one to be tidied and reopened, and each wrapper that
14
+ # comes off to be removed.
14
15
  class SnippetRevision # :nodoc:
15
16
  def initialize(path:, text:, syntax:, author:)
16
17
  @path = path
17
18
  @original = text
18
19
  @syntax = syntax
19
20
  @author = author
20
- @lines = syntax.cleaned(text.lines)
21
+ @lines = text.lines
21
22
  @snippets = syntax.snippets_in(@lines)
22
23
  @notices = []
23
- # Blame reads the cleaned lines the snippets were found in, not the file
24
- # on disk.
25
- @blame = Blame.new(path:, contents: @lines.join)
24
+ @wrapped = 0
25
+ @blame = Blame.new(path:, contents: text)
26
26
  end
27
27
 
28
28
  # Revises from the last snippet back, so the earlier ones stay on the lines
@@ -44,11 +44,10 @@ class SnippetRevision # :nodoc:
44
44
  def messages
45
45
  return [] unless changed?
46
46
 
47
- wrapped = @snippets.count(&:bare?)
48
- summary = if wrapped.zero?
47
+ summary = if @wrapped.zero?
49
48
  "Updated: #{@path}"
50
49
  else
51
- "Updated: #{@path} (#{wrapped} code block(s))"
50
+ "Updated: #{@path} (#{@wrapped} code block(s))"
52
51
  end
53
52
  [*@notices.map { |notice| " #{notice}" }, summary]
54
53
  end
@@ -65,6 +64,7 @@ class SnippetRevision # :nodoc:
65
64
  copyrights = contributions(snippet).map { |contribution| Copyright.of(contribution) }.sort_by(&:holder)
66
65
  @lines.insert(snippet.last + 1, *@syntax.closing(snippet))
67
66
  @lines.insert(snippet.first, *@syntax.opening(snippet, copyrights))
67
+ @wrapped += 1
68
68
  end
69
69
 
70
70
  def reopen(snippet, renewal)
@@ -72,4 +72,21 @@ class SnippetRevision # :nodoc:
72
72
  @lines[opening.span] = @syntax.opening(snippet, opening.copyrights)
73
73
  @notices.concat(renewal.notices)
74
74
  end
75
+
76
+ # A wrapper ends right after its code, so blank lines between them move
77
+ # below it.
78
+ def close(snippet, closing)
79
+ after = snippet.last + 1
80
+ between = @lines[after, closing.first - after]
81
+ @lines[after, closing.last + 1 - after] = @lines[closing] + between
82
+ end
83
+
84
+ # REUSE 3.3 wants the snippet tag where older wrappers used the file tag.
85
+ def retag(opening)
86
+ @lines[opening.span] = @lines[opening.span].map { |line| Snippet.tag(line) }
87
+ end
88
+
89
+ def remove(span)
90
+ @lines[span] = []
91
+ end
75
92
  end
@@ -0,0 +1,20 @@
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 wrapper that comes off, with the snippets it held, which are revised on
9
+ # their own.
10
+ class Unwrapping < Data.define(:opening, :closing, :contents) # :nodoc:
11
+ def first
12
+ opening.first
13
+ end
14
+
15
+ def revise(revision)
16
+ revision.remove(closing)
17
+ contents.reverse_each { |snippet| snippet.revise(revision) }
18
+ revision.remove(opening)
19
+ end
20
+ end
@@ -5,25 +5,41 @@
5
5
  # SPDX-License-Identifier: AGPL-3.0-or-later
6
6
  #++
7
7
 
8
- # A Snippet that already has its wrapper, opened by its SnippetOpening.
9
- class WrappedSnippet < Data.define(:snippet, :opening) # :nodoc:
8
+ require_relative "quoted_snippet"
9
+ require_relative "unwrapping"
10
+
11
+ # A Snippet that already has its wrapper, opened by its SnippetOpening and
12
+ # closed on the lines of +closing+.
13
+ class WrappedSnippet < Data.define(:snippet, :opening, :closing) # :nodoc:
14
+ # Quoted code keeps its notices; the rest is ours to renew.
15
+ def self.found(snippet:, opening:, closing:, license:)
16
+ if opening.quotes?(license)
17
+ QuotedSnippet.new(snippet:, opening:)
18
+ else
19
+ new(snippet:, opening:, closing:)
20
+ end
21
+ end
22
+
10
23
  def first
11
24
  snippet.first
12
25
  end
13
26
 
14
27
  def covers?(other)
15
- other.first.between?(snippet.first, snippet.last)
28
+ snippet.covers?(other)
16
29
  end
17
30
 
18
31
  def blame_range
19
32
  snippet.blame_range
20
33
  end
21
34
 
22
- def bare?
23
- false
35
+ # One of ours around prose comes off, with the examples it held.
36
+ def around_prose(contents)
37
+ Unwrapping.new(opening: opening.span, closing:, contents:)
24
38
  end
25
39
 
26
40
  def revise(revision)
41
+ revision.close(snippet, closing)
42
+ revision.retag(opening)
27
43
  renewal = opening.renewal(revision.contributions(self), revision.license)
28
44
  revision.reopen(snippet, renewal) if renewal.changed?
29
45
  end
@@ -8,6 +8,6 @@
8
8
  module RatatuiRuby
9
9
  module Devtools
10
10
  # Current version of the ratatui_ruby-devtools gem.
11
- VERSION = "0.4.0"
11
+ VERSION = "0.4.1"
12
12
  end
13
13
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ratatui_ruby-devtools
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kerrick Long
@@ -220,11 +220,13 @@ files:
220
220
  - lib/ratatui_ruby/devtools/tasks/license/header_revision.rb
221
221
  - lib/ratatui_ruby/devtools/tasks/license/licensed_file.rb
222
222
  - lib/ratatui_ruby/devtools/tasks/license/licensing.rb
223
+ - lib/ratatui_ruby/devtools/tasks/license/markdown_comment.rb
223
224
  - lib/ratatui_ruby/devtools/tasks/license/markdown_document.rb
224
225
  - lib/ratatui_ruby/devtools/tasks/license/markdown_files.rb
225
226
  - lib/ratatui_ruby/devtools/tasks/license/markdown_header_syntax.rb
226
227
  - lib/ratatui_ruby/devtools/tasks/license/markdown_snippet_syntax.rb
227
228
  - lib/ratatui_ruby/devtools/tasks/license/missing_header.rb
229
+ - lib/ratatui_ruby/devtools/tasks/license/quoted_snippet.rb
228
230
  - lib/ratatui_ruby/devtools/tasks/license/rbs_files.rb
229
231
  - lib/ratatui_ruby/devtools/tasks/license/rbs_header_syntax.rb
230
232
  - lib/ratatui_ruby/devtools/tasks/license/rdoc_comment.rb
@@ -245,6 +247,7 @@ files:
245
247
  - lib/ratatui_ruby/devtools/tasks/license/spdx_header.rb
246
248
  - lib/ratatui_ruby/devtools/tasks/license/unknown_file.rb
247
249
  - lib/ratatui_ruby/devtools/tasks/license/unlicensed_files.rb
250
+ - lib/ratatui_ruby/devtools/tasks/license/unwrapping.rb
248
251
  - lib/ratatui_ruby/devtools/tasks/license/wrapped_snippet.rb
249
252
  - lib/ratatui_ruby/devtools/tasks/license_tasks.rb
250
253
  - lib/ratatui_ruby/devtools/tasks/lint/gem_lockfile.rb