ratatui_ruby-devtools 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. checksums.yaml +7 -0
  2. data/.builds/ruby-4.0.yml +38 -0
  3. data/.pre-commit-config.yaml +16 -0
  4. data/.rubocop.yml +8 -0
  5. data/AGENTS.md +72 -0
  6. data/CHANGELOG.md +23 -0
  7. data/LICENSE +661 -0
  8. data/LICENSES/AGPL-3.0-or-later.txt +661 -0
  9. data/LICENSES/CC-BY-SA-4.0.txt +427 -0
  10. data/LICENSES/CC0-1.0.txt +121 -0
  11. data/LICENSES/MIT-0.txt +16 -0
  12. data/LICENSES/MIT.txt +18 -0
  13. data/README.md +199 -0
  14. data/REUSE.toml +18 -0
  15. data/Rakefile +13 -0
  16. data/bin/agent_rake +13 -0
  17. data/bin/announce +13 -0
  18. data/bin/console +14 -0
  19. data/bin/consolidate_md +13 -0
  20. data/bin/hbs +13 -0
  21. data/bin/setup +17 -0
  22. data/doc/contributors/documentation_style.md +121 -0
  23. data/doc/custom.css +22 -0
  24. data/exe/agent_rake +96 -0
  25. data/exe/announce +1120 -0
  26. data/exe/consolidate_md +246 -0
  27. data/exe/hbs +670 -0
  28. data/exe/scaffold +662 -0
  29. data/lib/ratatui_ruby/devtools/tasks/autodoc/examples.rb +133 -0
  30. data/lib/ratatui_ruby/devtools/tasks/autodoc/member.rb +116 -0
  31. data/lib/ratatui_ruby/devtools/tasks/autodoc/name.rb +33 -0
  32. data/lib/ratatui_ruby/devtools/tasks/autodoc.rake +21 -0
  33. data/lib/ratatui_ruby/devtools/tasks/bump/cargo_lockfile.rb +38 -0
  34. data/lib/ratatui_ruby/devtools/tasks/bump/changelog.rb +67 -0
  35. data/lib/ratatui_ruby/devtools/tasks/bump/header.rb +43 -0
  36. data/lib/ratatui_ruby/devtools/tasks/bump/history.rb +50 -0
  37. data/lib/ratatui_ruby/devtools/tasks/bump/links.rb +78 -0
  38. data/lib/ratatui_ruby/devtools/tasks/bump/manifest.rb +63 -0
  39. data/lib/ratatui_ruby/devtools/tasks/bump/ruby_gem.rb +77 -0
  40. data/lib/ratatui_ruby/devtools/tasks/bump/sem_ver.rb +63 -0
  41. data/lib/ratatui_ruby/devtools/tasks/bump/unreleased_section.rb +75 -0
  42. data/lib/ratatui_ruby/devtools/tasks/bump.rake +80 -0
  43. data/lib/ratatui_ruby/devtools/tasks/cargo.rake +47 -0
  44. data/lib/ratatui_ruby/devtools/tasks/doc.rake +887 -0
  45. data/lib/ratatui_ruby/devtools/tasks/example_viewer.html.erb +172 -0
  46. data/lib/ratatui_ruby/devtools/tasks/license/headers_md.rb +276 -0
  47. data/lib/ratatui_ruby/devtools/tasks/license/headers_rb.rb +236 -0
  48. data/lib/ratatui_ruby/devtools/tasks/license/license_utils.rb +143 -0
  49. data/lib/ratatui_ruby/devtools/tasks/license/snippets_md.rb +353 -0
  50. data/lib/ratatui_ruby/devtools/tasks/license/snippets_rdoc.rb +186 -0
  51. data/lib/ratatui_ruby/devtools/tasks/license.rake +91 -0
  52. data/lib/ratatui_ruby/devtools/tasks/lint.rake +84 -0
  53. data/lib/ratatui_ruby/devtools/tasks/rdoc_config.rb +45 -0
  54. data/lib/ratatui_ruby/devtools/tasks/resources/build.yml.erb +54 -0
  55. data/lib/ratatui_ruby/devtools/tasks/resources/rubies.yml +7 -0
  56. data/lib/ratatui_ruby/devtools/tasks/reuse.rake +104 -0
  57. data/lib/ratatui_ruby/devtools/tasks/sourcehut.rake +94 -0
  58. data/lib/ratatui_ruby/devtools/tasks/test.rake +18 -0
  59. data/lib/ratatui_ruby/devtools/templates/.builds/ruby.yml.erb +47 -0
  60. data/lib/ratatui_ruby/devtools/templates/.gitignore.erb +18 -0
  61. data/lib/ratatui_ruby/devtools/templates/.pre-commit-config.yaml.erb +16 -0
  62. data/lib/ratatui_ruby/devtools/templates/.rubocop.yml.erb +8 -0
  63. data/lib/ratatui_ruby/devtools/templates/AGENTS.md.erb +65 -0
  64. data/lib/ratatui_ruby/devtools/templates/CHANGELOG.md.erb +18 -0
  65. data/lib/ratatui_ruby/devtools/templates/Gemfile.erb +32 -0
  66. data/lib/ratatui_ruby/devtools/templates/README.md.erb +127 -0
  67. data/lib/ratatui_ruby/devtools/templates/REUSE.toml.erb +33 -0
  68. data/lib/ratatui_ruby/devtools/templates/Rakefile.erb +29 -0
  69. data/lib/ratatui_ruby/devtools/templates/bin/console.erb +18 -0
  70. data/lib/ratatui_ruby/devtools/templates/bin/setup.erb +24 -0
  71. data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_architecture.md.erb +16 -0
  72. data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_testing.md.erb +49 -0
  73. data/lib/ratatui_ruby/devtools/templates/doc/custom.css.erb +24 -0
  74. data/lib/ratatui_ruby/devtools/templates/doc/getting_started/quickstart.md.erb +56 -0
  75. data/lib/ratatui_ruby/devtools/templates/doc/images/.gitkeep +0 -0
  76. data/lib/ratatui_ruby/devtools/templates/doc/index.md.erb +25 -0
  77. data/lib/ratatui_ruby/devtools/templates/exe/.gitkeep +0 -0
  78. data/lib/ratatui_ruby/devtools/templates/gemspec.erb +58 -0
  79. data/lib/ratatui_ruby/devtools/templates/mise.toml.erb +12 -0
  80. data/lib/ratatui_ruby/devtools/templates/tasks/example_viewer.html.erb +174 -0
  81. data/lib/ratatui_ruby/devtools/templates/tasks/resources/build.yml.erb +62 -0
  82. data/lib/ratatui_ruby/devtools/templates/tasks/resources/index.html.erb +46 -0
  83. data/lib/ratatui_ruby/devtools/templates/tasks/resources/rubies.yml.erb +9 -0
  84. data/lib/ratatui_ruby/devtools/templates/vendor/goodcop/base.yml +1047 -0
  85. data/lib/ratatui_ruby/devtools/version.rb +13 -0
  86. data/lib/ratatui_ruby/devtools.rb +137 -0
  87. data/mise.toml +7 -0
  88. data/sig/ratatui_ruby/devtools.rbs +15 -0
  89. data/vendor/goodcop/base.yml +1047 -0
  90. metadata +252 -0
@@ -0,0 +1,133 @@
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
+ # Synchronizes code snippets with documentation.
9
+ #
10
+ # Documentation contains code examples. Source files change. Copy-pasting
11
+ # leads to stale examples. Tests pass but the README lies.
12
+ #
13
+ # This module scans markdown files for SYNC markers and replaces content
14
+ # with live source. The documentation stays accurate. No manual updates.
15
+ #
16
+ # Use it to keep README examples in sync with working code.
17
+ module Autodoc
18
+ # Synchronizes code snippets from source files into markdown.
19
+ #
20
+ # Markdown files contain embedded code examples. Maintaining them manually
21
+ # drifts from the source. This class scans for SYNC markers and injects
22
+ # live code from the referenced files.
23
+ #
24
+ # Use it to sync README.md examples with your actual implementation.
25
+ #
26
+ # === Example
27
+ #
28
+ # In your README.md:
29
+ # <!-- SYNC:START:examples/hello/app.rb:main -->
30
+ # ```ruby
31
+ # # This content gets replaced
32
+ # ```
33
+ # <!-- SYNC:END -->
34
+ #
35
+ # Then run:
36
+ # Autodoc::Examples.sync
37
+ #
38
+ class Examples
39
+ # Synchronize all README files in the repository.
40
+ def self.sync
41
+ new.sync
42
+ end
43
+
44
+ # Synchronize all README files.
45
+ #
46
+ # Scans for SYNC markers in markdown files and replaces content with
47
+ # source file snippets.
48
+ def sync
49
+ Dir.glob("{README.md,doc/**/*.md,examples/*/README.md}").each do |readme_path|
50
+ sync_readme(readme_path)
51
+ end
52
+ end
53
+
54
+ private def sync_readme(readme_path)
55
+ content = File.read(readme_path)
56
+ dir = File.dirname(readme_path)
57
+
58
+ new_content = content.gsub(/<!-- SYNC:START:([^ ]+) -->.*?<!-- SYNC:END -->/m) do
59
+ marker_info = $1
60
+ source_rel_path, segment_id = marker_info.split(":")
61
+
62
+ # Support both repo-root-relative paths (no leading ./) and file-relative paths
63
+ source_path = if source_rel_path.start_with?("./", "../")
64
+ File.join(dir, source_rel_path)
65
+ else
66
+ source_rel_path # Already relative to repo root
67
+ end
68
+
69
+ unless File.exist?(source_path)
70
+ warn "Warning: Source file not found: #{source_path}"
71
+ next $&
72
+ end
73
+
74
+ source_content = File.read(source_path)
75
+ extracted_content = if segment_id
76
+ extract_segment(source_content, segment_id, source_path)
77
+ else
78
+ source_content
79
+ end
80
+
81
+ # Detect language from extension
82
+ ext = File.extname(source_path).delete(".")
83
+ lang = (ext == "rb") ? "ruby" : ext
84
+
85
+ # Build replacement
86
+ "<!-- SYNC:START:#{marker_info} -->\n```#{lang}\n#{extracted_content}```\n<!-- SYNC:END -->"
87
+ end
88
+
89
+ if new_content != content
90
+ puts "Syncing #{readme_path}..."
91
+ File.write(readme_path, new_content)
92
+ end
93
+ end
94
+
95
+ # Extracts a named segment from source content.
96
+ #
97
+ # Source files contain segment markers like <tt>[SYNC:START:main]</tt>.
98
+ # This method extracts the content between matching markers.
99
+ #
100
+ # [content] The source file content.
101
+ # [segment_id] The segment name to extract.
102
+ # [source_path] The source file path (for error messages).
103
+ def extract_segment(content, segment_id, source_path)
104
+ start_marker = /#\s*\[SYNC:START:#{segment_id}\]/
105
+ end_marker = /#\s*\[SYNC:END:#{segment_id}\]/
106
+
107
+ lines = content.lines
108
+ start_idx = lines.find_index { |l| l =~ start_marker }
109
+ end_idx = lines.find_index { |l| l =~ end_marker }
110
+
111
+ if start_idx && end_idx
112
+ "#{unindent(lines[(start_idx + 1)...end_idx].join).strip}\n"
113
+ else
114
+ warn "Warning: Segment '#{segment_id}' not found in #{source_path}"
115
+ content
116
+ end
117
+ end
118
+
119
+ # Removes common leading indentation from text.
120
+ #
121
+ # Code segments often have indentation from their context. This method
122
+ # strips the common prefix so the output looks clean.
123
+ #
124
+ # [text] The text to unindent.
125
+ def unindent(text)
126
+ lines = text.lines
127
+ return text if lines.empty?
128
+
129
+ indentation = lines.grep(/\S/).map { |l| l[/^\s*/].length }.min || 0
130
+ lines.map { |l| (l.length > indentation) ? l[indentation..-1] : "#{l.strip}\n" }.join
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,116 @@
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
+ module Autodoc
9
+ # Member types for autodoc generation.
10
+ #
11
+ # Autodoc generates RBS types and RDoc comments for TUI factory methods.
12
+ # Each method type (delegate, factory, helper) has different documentation
13
+ # patterns. Writing these by hand is tedious and error-prone.
14
+ #
15
+ # These Data classes generate consistent RBS and RDoc output for each
16
+ # member type. Feed them method names. Get documentation.
17
+ module Member
18
+ # A method that delegates to an identically-named module method.
19
+ #
20
+ # Some instance methods simply call a module-level method. This class
21
+ # generates the RBS signature and RDoc comment for such methods.
22
+ #
23
+ # [name] The method name.
24
+ class Delegate < Data.define(:name)
25
+ # Generates an RBS type signature for this delegate.
26
+ #
27
+ # Autodoc writes .rbs files. Each method needs a signature. Delegates
28
+ # forward all arguments, so they use a generic variadic signature.
29
+ def rbs
30
+ " def #{name}: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped"
31
+ end
32
+
33
+ # Generates RDoc comment lines for this delegate.
34
+ #
35
+ # Autodoc writes method documentation. Each method needs a comment.
36
+ # Delegates describe forwarding to the module method. This returns
37
+ # the correctly-formatted comment lines.
38
+ def rdoc
39
+ [
40
+ " # :method: #{name}",
41
+ " # :call-seq: #{name}(*args, **kwargs, &block)",
42
+ " #",
43
+ " # Delegates to RatatuiRuby.#{name}.",
44
+ " #",
45
+ ]
46
+ end
47
+ end
48
+
49
+ # A factory method that creates instances of a widget class.
50
+ #
51
+ # Factory methods like <tt>paragraph</tt> create <tt>Paragraph.new</tt>.
52
+ # This class generates the RBS signature and RDoc comment.
53
+ #
54
+ # [name] The method name.
55
+ # [const_name] The constant name (e.g., <tt>Paragraph</tt>).
56
+ class Factory < Data.define(:name, :const_name)
57
+ # Generates an RBS type signature for this factory.
58
+ #
59
+ # Autodoc writes .rbs files. Each method needs a signature. Factories
60
+ # forward all arguments to constructors, so they use a generic variadic
61
+ # signature.
62
+ def rbs
63
+ " def #{name}: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped"
64
+ end
65
+
66
+ # Generates RDoc comment lines for this factory.
67
+ #
68
+ # Autodoc writes method documentation. Each method needs a comment.
69
+ # Factories describe creating a widget instance. This returns the
70
+ # correctly-formatted comment lines.
71
+ def rdoc
72
+ [
73
+ " # :method: #{name}",
74
+ " # :call-seq: #{name}(*args, **kwargs, &block)",
75
+ " #",
76
+ " # Factory for RatatuiRuby::#{const_name}.new.",
77
+ " #",
78
+ ]
79
+ end
80
+ end
81
+
82
+ # A helper method that wraps a class method.
83
+ #
84
+ # Helper methods call class methods with simplified signatures. This
85
+ # class generates the RBS signature and RDoc comment.
86
+ #
87
+ # [name] The method name.
88
+ # [class_method] The class method being wrapped.
89
+ # [const_name] The constant name.
90
+ class Helper < Data.define(:name, :class_method, :const_name)
91
+ # Generates an RBS type signature for this helper.
92
+ #
93
+ # Autodoc writes .rbs files. Each method needs a signature. Helpers
94
+ # forward all arguments to class methods, so they use a generic variadic
95
+ # signature.
96
+ def rbs
97
+ " def #{name}: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped"
98
+ end
99
+
100
+ # Generates RDoc comment lines for this helper.
101
+ #
102
+ # Autodoc writes method documentation. Each method needs a comment.
103
+ # Helpers describe calling a class method. This returns the
104
+ # correctly-formatted comment lines.
105
+ def rdoc
106
+ [
107
+ " # :method: #{name}",
108
+ " # :call-seq: #{name}(*args, **kwargs, &block)",
109
+ " #",
110
+ " # Helper for RatatuiRuby::#{const_name}.#{class_method}.",
111
+ " #",
112
+ ]
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,33 @@
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
+ module Autodoc
9
+ # Wraps a name string with case conversion utilities.
10
+ #
11
+ # Ruby uses snake_case. Constants use PascalCase. Converting between them
12
+ # by hand invites typos. This class handles the conversion.
13
+ #
14
+ # [string] The name string.
15
+ class Name < Data.define(:string)
16
+ # Converts the name to snake_case.
17
+ #
18
+ # === Example
19
+ #
20
+ # Autodoc::Name.new("BarChart").snake # => "bar_chart"
21
+ def snake
22
+ string.to_s
23
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
24
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
25
+ .downcase
26
+ end
27
+
28
+ # Returns the original string.
29
+ def to_s
30
+ string.to_s
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "autodoc/examples"
9
+
10
+ namespace :autodoc do
11
+ desc "Update all automatically generated documentation"
12
+ task all: [:examples]
13
+
14
+ desc "Sync code snippets in example READMEs with source files"
15
+ task :examples do
16
+ Autodoc::Examples.sync
17
+ end
18
+ end
19
+
20
+ desc "Update all automatically generated documentation"
21
+ task autodoc: "autodoc:all"
@@ -0,0 +1,38 @@
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
+ # Refreshes Cargo.lock after version changes.
9
+ #
10
+ # Rust crates have lockfiles that pin dependency versions. After updating
11
+ # Cargo.toml, the lockfile becomes stale. Running cargo update fixes it.
12
+ #
13
+ # This class wraps the lockfile refresh operation. It runs cargo update
14
+ # in the crate directory. Use it after bumping Rust extension versions.
15
+ #
16
+ # [path] The path to the Cargo.lock file.
17
+ # [dir] The directory containing the Cargo.toml.
18
+ # [name] The crate name to update.
19
+ class CargoLockfile < Data.define(:path, :dir, :name)
20
+ # Checks whether the lockfile exists on disk.
21
+ #
22
+ # Pure Ruby gems have no Cargo.lock. Refreshing a missing file fails. Check
23
+ # this before calling refresh to avoid errors.
24
+ def exists?
25
+ File.exist?(path)
26
+ end
27
+
28
+ # Refreshes the lockfile by running cargo update.
29
+ #
30
+ # Runs <tt>cargo update -p {name} --offline</tt> in the crate directory.
31
+ def refresh
32
+ return unless exists?
33
+
34
+ Dir.chdir(dir) do
35
+ system("cargo update -p #{name} --offline")
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,67 @@
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 "links"
9
+ require_relative "unreleased_section"
10
+ require_relative "history"
11
+ require_relative "header"
12
+
13
+ # Manages the project's CHANGELOG.md file.
14
+ #
15
+ # Changelogs track user-facing changes. During a release, the Unreleased
16
+ # section becomes a versioned section. Links update. The Unreleased section
17
+ # resets. Doing this by hand invites errors.
18
+ #
19
+ # This class orchestrates the changelog update. It parses the sections, moves
20
+ # content, updates links, and writes the result. One call. Clean changelog.
21
+ #
22
+ # Use it during version bumps to update the release notes.
23
+ class Changelog
24
+ # Creates a new Changelog manager.
25
+ #
26
+ # [path] The path to the changelog file. Defaults to <tt>CHANGELOG.md</tt>.
27
+ def initialize(path: "CHANGELOG.md")
28
+ @path = path
29
+ end
30
+
31
+ # Releases a new version in the changelog.
32
+ #
33
+ # Moves unreleased changes to a dated version section. Resets the Unreleased
34
+ # section. Updates the comparison links.
35
+ #
36
+ # [new_version] The SemVer or version string to release.
37
+ def release(new_version)
38
+ content = File.read(@path)
39
+
40
+ header = Header.parse(content)
41
+ unreleased = UnreleasedSection.parse(content)
42
+ links = Links.from_markdown(content)
43
+
44
+ raise "Could not parse CHANGELOG.md" unless header && unreleased && links
45
+
46
+ history = History.parse(content, header.length, unreleased.to_s.length, links.to_s)
47
+
48
+ links.release(new_version)
49
+ history.add(unreleased.as_version(new_version))
50
+
51
+ File.write(@path, "#{header}#{UnreleasedSection.fresh}\n\n#{history}\n#{links}")
52
+ nil
53
+ end
54
+
55
+ # Generates a commit message for the release.
56
+ #
57
+ # Extracts the unreleased changes and formats them for a commit body.
58
+ #
59
+ # [version] The version being released.
60
+ def commit_message(version)
61
+ content = File.read(@path)
62
+ unreleased = UnreleasedSection.parse(content)
63
+ return nil unless unreleased
64
+
65
+ "chore: release v#{version}\n\n#{unreleased.commit_body}"
66
+ end
67
+ end
@@ -0,0 +1,43 @@
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
+ # Manages the header section of a changelog.
9
+ #
10
+ # Changelogs start with a header: title, description, format reference. During
11
+ # updates, this section stays unchanged. Extracting it ensures safe rewrites.
12
+ #
13
+ # This class parses the header from changelog markdown. It preserves it
14
+ # during modifications.
15
+ class Header
16
+ # Regex to match everything before the Unreleased section.
17
+ PATTERN = /^(.*?)(?=## \[Unreleased\])/m
18
+
19
+ # Parses the header from changelog content.
20
+ #
21
+ # [content] The full changelog text.
22
+ def self.parse(content)
23
+ match = content.match(PATTERN)
24
+ new(match[1]) if match
25
+ end
26
+
27
+ # Creates a new Header.
28
+ #
29
+ # [content] The raw header text.
30
+ def initialize(content)
31
+ @content = content.dup
32
+ end
33
+
34
+ # Returns the byte length of the header.
35
+ def length
36
+ @content.length
37
+ end
38
+
39
+ # Returns the header as a string.
40
+ def to_s
41
+ @content
42
+ end
43
+ end
@@ -0,0 +1,50 @@
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
+ # Manages the versioned history section of a changelog.
9
+ #
10
+ # Changelogs contain past version entries below the Unreleased section. During
11
+ # a release, new version entries prepend to this history. Manipulating it
12
+ # manually risks corruption.
13
+ #
14
+ # This class extracts history from the changelog. It prepends new version
15
+ # entries. It serializes back to markdown.
16
+ #
17
+ # Use it during release preparation.
18
+ class History
19
+ # Parses the history section from changelog content.
20
+ #
21
+ # [content] The full changelog text.
22
+ # [header_length] Length of the header section.
23
+ # [unreleased_length] Length of the Unreleased section.
24
+ # [links_text] The links section text (used as end marker).
25
+ def self.parse(content, header_length, unreleased_length, links_text)
26
+ start = header_length + unreleased_length
27
+ text = "#{content[start...(content.index(links_text))].strip}\n"
28
+ new(text)
29
+ end
30
+
31
+ # Creates a new History.
32
+ #
33
+ # [content] The raw history text.
34
+ def initialize(content)
35
+ @content = content.dup
36
+ end
37
+
38
+ # Adds a new versioned section to the beginning of history.
39
+ #
40
+ # [section] The version section text to prepend.
41
+ def add(section)
42
+ @content = "#{"#{section}\n\n#{@content}".strip}\n"
43
+ nil
44
+ end
45
+
46
+ # Returns the history as a string.
47
+ def to_s
48
+ @content
49
+ end
50
+ end
@@ -0,0 +1,78 @@
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
+ # Manages the version comparison links at the botton of the changelog.
9
+ #
10
+ # Release automation needs to update links. Manually calculating git diff URLs
11
+ # for every release is tedious and error-prone. SourceHut does not have
12
+ # standard comparison views, complicating matters further.
13
+ #
14
+ # This class manages the collection of links. It parses them from the markdown.
15
+ # It generates the correct tree links for SourceHut. It properly shifts the
16
+ # "Unreleased" pointer.
17
+ #
18
+ # Use it to update the changelog during a release.
19
+ class Links
20
+ # Regex to match the markdown links section.
21
+ #
22
+ # Changelogs end with reference-style links. Scanning the whole document is
23
+ # wasteful. This pattern finds where the links begin.
24
+ PATTERN = /^(\[Unreleased\]: .*)$/m
25
+
26
+ # Regex to extract the base URL from the Unreleased link.
27
+ #
28
+ # New version links derive from the Unreleased URL structure. Parsing the
29
+ # URL components enables programmatic link generation.
30
+ UNRELEASED_PATTERN = %r{^\[Unreleased\]: (.*?/refs/)HEAD$}
31
+
32
+ # Creates a Links object from the full markdown content.
33
+ #
34
+ # [content] String. The full text of the changelog.
35
+ def self.from_markdown(content)
36
+ match = content.match(PATTERN)
37
+ return unless match
38
+
39
+ new(match[1].strip)
40
+ end
41
+
42
+ # Returns the raw text of the links.
43
+ attr_reader :text
44
+
45
+ # Creates a new Links object.
46
+ #
47
+ # [text] String. The raw text of the links section.
48
+ def initialize(text)
49
+ @text = text.dup
50
+ end
51
+
52
+ # Releases a new version.
53
+ #
54
+ # Updates the "Unreleased" link to point to the new head. Adds a new link for
55
+ # the just-released version pointing to its specific tag.
56
+ #
57
+ # [version] String. The new version number (e.g., <tt>"0.5.0"</tt>).
58
+ def release(version)
59
+ return unless base_url
60
+
61
+ new_unreleased = "[Unreleased]: #{base_url}HEAD" # .../HEAD
62
+ new_version_link = "[#{version}]: #{base_url}v#{version}" # .../v1.0.0
63
+
64
+ @text.sub!(UNRELEASED_PATTERN, "#{new_unreleased}\n#{new_version_link}")
65
+ self
66
+ end
67
+
68
+ # Returns the string representation of the links.
69
+ def to_s
70
+ @text
71
+ end
72
+
73
+ # The base URL for the repository's references.
74
+ private def base_url
75
+ match = @text.match(UNRELEASED_PATTERN)
76
+ match[1] if match
77
+ end
78
+ end
@@ -0,0 +1,63 @@
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
+ # Represents a file that contains a version number.
9
+ #
10
+ # Gems have version numbers in multiple places: version.rb, Cargo.toml, etc.
11
+ # Finding and updating them by hand risks inconsistency. One file says 1.2.3,
12
+ # another says 1.2.2.
13
+ #
14
+ # This class wraps a file path with a regex pattern. It reads the current
15
+ # version and writes new versions. Use lookaround patterns to match precisely.
16
+ #
17
+ # [path] The file path.
18
+ # [pattern] A regex with lookarounds to match the version string.
19
+ # [primary] Whether this is the primary source of truth.
20
+ #
21
+ # === Example
22
+ #
23
+ # manifest = Manifest.new(
24
+ # path: "lib/my_gem/version.rb",
25
+ # pattern: /(?<=VERSION = ")[^"]+(?=")/,
26
+ # primary: true
27
+ # )
28
+ # manifest.version.to_s # => "1.2.3"
29
+ #
30
+ class Manifest < Data.define(:path, :pattern, :primary)
31
+ # Creates a new Manifest.
32
+ #
33
+ # [path] The file path.
34
+ # [pattern] A regex with lookarounds to match the version string.
35
+ # [primary] Whether this is the primary source of truth.
36
+ def initialize(path:, pattern:, primary: false)
37
+ super
38
+ end
39
+
40
+ # Reads the file content.
41
+ def read
42
+ File.read(path)
43
+ end
44
+
45
+ # Returns the current version from this manifest.
46
+ def version
47
+ content = read
48
+ match = content.match(pattern)
49
+ raise "Version missing in manifest #{path}" unless match
50
+
51
+ SemVer.parse(match[0])
52
+ end
53
+
54
+ # Writes a new version to this manifest.
55
+ #
56
+ # [version] The SemVer to write.
57
+ def write(version)
58
+ return unless File.exist?(path)
59
+
60
+ new_content = read.gsub(pattern, version.to_s)
61
+ File.write(path, new_content)
62
+ end
63
+ end