railbow 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.
@@ -0,0 +1,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+ require "date"
5
+ require_relative "git_utils"
6
+ require_relative "formatters/base"
7
+ require_relative "logo"
8
+ require_relative "color_assigner"
9
+
10
+ module Railbow
11
+ module NotesFormatter
12
+ RESET = Formatters::Base::RESET
13
+ BOLD = Formatters::Base::BOLD
14
+ DIM = Formatters::Base::DIM
15
+ RED = Formatters::Base::RED
16
+ YELLOW = Formatters::Base::YELLOW
17
+ CYAN = Formatters::Base::CYAN
18
+ GREEN = Formatters::Base::GREEN
19
+ WHITE = Formatters::Base::BRIGHT_WHITE
20
+
21
+ BAR = "\u258e" # ▎
22
+
23
+ TAG_COLORS = {
24
+ "TODO" => YELLOW,
25
+ "FIXME" => RED,
26
+ "OPTIMIZE" => CYAN,
27
+ "HACK" => RED,
28
+ "NOTE" => GREEN
29
+ }.freeze
30
+
31
+ PALETTE = Formatters::Base::TABLE_PALETTE
32
+
33
+ def display(results, options = {})
34
+ return super if Railbow.plain?
35
+
36
+ if Railbow::Params.help?
37
+ print_help
38
+ return
39
+ end
40
+
41
+ author_mode = Railbow::Params.git_author
42
+ since_val = Railbow::Params.since
43
+ sort_mode = Railbow::Params.sort
44
+
45
+ since_date = Railbow::Params.parse_since(since_val, context: "annotations")
46
+ git_needed = %w[all me].include?(author_mode) || since_date || sort_mode == "date"
47
+
48
+ # Build blame cache for all files if git features are needed
49
+ blame_cache = {}
50
+ if git_needed
51
+ results.each_key do |source_file|
52
+ blame_cache[source_file] ||= blame_file(source_file)
53
+ end
54
+ end
55
+
56
+ # Flatten annotations into a list for filtering/sorting
57
+ entries = []
58
+ results.each do |source_file, annotations|
59
+ annotations.each do |annotation|
60
+ blame_info = blame_cache.dig(source_file, annotation.line)
61
+ entries << {
62
+ file: source_file,
63
+ annotation: annotation,
64
+ blame: blame_info
65
+ }
66
+ end
67
+ end
68
+
69
+ # Filter by SINCE
70
+ skipped = 0
71
+ if since_date
72
+ before = entries.size
73
+ entries = entries.select do |entry|
74
+ entry[:blame] && entry[:blame][:date] && entry[:blame][:date] >= since_date
75
+ end
76
+ skipped = before - entries.size
77
+ end
78
+
79
+ # Sort by date (newest first) when requested
80
+ if sort_mode == "date"
81
+ entries.sort_by! do |entry|
82
+ d = entry.dig(:blame, :date)
83
+ d ? -d.to_time.to_i : 0
84
+ end
85
+ end
86
+
87
+ # Determine "me" identity
88
+ git_email = nil
89
+ if author_mode == "me"
90
+ git_email = current_git_email
91
+ end
92
+
93
+ # Determine max location width for alignment when showing metadata
94
+ show_date = git_needed
95
+ max_loc_width = 0
96
+ author_colors = nil
97
+ author_display = {}
98
+ if author_mode == "all"
99
+ author_names = entries.filter_map { |e| e.dig(:blame, :author) }.uniq
100
+ author_colors = ColorAssigner.new(author_names)
101
+ author_display = Railbow::Params.format_authors(author_names)
102
+ end
103
+ if show_date || author_mode == "all"
104
+ entries.each do |entry|
105
+ loc = "#{entry[:file]}:#{entry[:annotation].line}"
106
+ max_loc_width = loc.length if loc.length > max_loc_width
107
+ end
108
+ end
109
+
110
+ # Group entries back by file for bar coloring
111
+ file_counts = Hash.new(0)
112
+ entries.each { |e| file_counts[e[:file]] += 1 }
113
+
114
+ # Display
115
+ entries.each do |entry|
116
+ source_file = entry[:file]
117
+ annotation = entry[:annotation]
118
+ blame_info = entry[:blame]
119
+
120
+ is_mine = author_mode == "me" && git_email && blame_info &&
121
+ blame_info[:email] == git_email
122
+
123
+ bar_color = if file_counts[source_file] > 1
124
+ color_code = PALETTE[Zlib.crc32(source_file.to_s) % PALETTE.size]
125
+ "\e[38;5;#{color_code}m"
126
+ else
127
+ DIM
128
+ end
129
+ colored_bar = "#{bar_color}#{BAR}#{RESET}"
130
+ # When highlighting "me", use bright white bar
131
+ colored_bar = "#{WHITE}#{BAR}#{RESET}" if is_mine
132
+
133
+ tag = annotation.tag
134
+ tag_color = TAG_COLORS[tag] || DIM
135
+ location = "#{source_file}:#{annotation.line}"
136
+ colored_tag = "#{tag_color}#{BOLD}#{tag}#{RESET}"
137
+ note = annotation.text.to_s.encode("UTF-8", invalid: :replace, undef: :replace)
138
+
139
+ # Build the file:line row with optional author+date
140
+ loc_line = " #{colored_bar} #{location}"
141
+
142
+ if author_mode == "all" && blame_info
143
+ raw_author = blame_info[:author] || ""
144
+ author_name = author_display[raw_author] || Railbow::Params.format_author(raw_author)
145
+ blame_date = blame_info[:date] ? blame_info[:date].strftime("%Y-%m-%d") : ""
146
+ padding = " " * [(max_loc_width - location.length + 2), 2].max
147
+ colored_author = "#{author_colors.color_for(raw_author)}#{author_name}#{RESET}"
148
+ meta = "#{colored_author} #{DIM}#{blame_date}#{RESET}"
149
+ loc_line = " #{colored_bar} #{location}#{padding}#{meta}"
150
+ elsif author_mode == "me" && is_mine && blame_info
151
+ blame_date = blame_info[:date] ? blame_info[:date].strftime("%Y-%m-%d") : ""
152
+ loc_line = " #{colored_bar} #{WHITE}#{location}#{RESET} #{DIM}#{blame_date}#{RESET}"
153
+ elsif author_mode != "all" && show_date && blame_info
154
+ blame_date = blame_info[:date] ? blame_info[:date].strftime("%Y-%m-%d") : ""
155
+ padding = " " * [(max_loc_width - location.length + 2), 2].max
156
+ loc_line = " #{colored_bar} #{location}#{padding}#{DIM}#{blame_date}#{RESET}"
157
+ end
158
+
159
+ puts loc_line
160
+
161
+ # Wrap note text so continuation lines keep the bar and align after TAG
162
+ prefix = " #{colored_bar} "
163
+ tag_str = "#{colored_tag} "
164
+ indent_width = 1 + 1 + 1 + 3 + tag.length + 1 # " " + BAR + " " + " " + TAG + " "
165
+ continuation_prefix = " #{colored_bar} #{" " * (tag.length + 1)}"
166
+
167
+ # Apply "me" highlighting to note text
168
+ if is_mine
169
+ tag_str = "#{WHITE}#{tag}#{RESET} "
170
+ end
171
+
172
+ term_w = begin
173
+ ($stdout.tty? && $stdout.respond_to?(:winsize)) ? $stdout.winsize[1] : 120
174
+ rescue
175
+ 120
176
+ end
177
+ max_note_width = [term_w - indent_width, 20].max
178
+
179
+ if note.length <= max_note_width
180
+ note_text = is_mine ? "#{WHITE}#{note}#{RESET}" : note
181
+ puts "#{prefix}#{tag_str}#{note_text}"
182
+ else
183
+ lines = word_wrap_simple(note, max_note_width)
184
+ first_line = is_mine ? "#{WHITE}#{lines.first}#{RESET}" : lines.first
185
+ puts "#{prefix}#{tag_str}#{first_line}"
186
+ lines[1..].each do |line|
187
+ line_text = is_mine ? "#{WHITE}#{line}#{RESET}" : line
188
+ puts "#{continuation_prefix}#{line_text}"
189
+ end
190
+ end
191
+ end
192
+
193
+ if skipped > 0
194
+ puts
195
+ puts " #{DIM}#{skipped} annotation(s) older than #{since_val} hidden#{RESET}"
196
+ end
197
+
198
+ puts
199
+ end
200
+
201
+ private
202
+
203
+ def blame_file(path)
204
+ return {} unless File.exist?(path)
205
+
206
+ output, status = Railbow::GitUtils.capture2("blame", "--porcelain", path)
207
+ return {} unless status.success?
208
+
209
+ result = {}
210
+ current_line = nil
211
+ current_author = nil
212
+ current_email = nil
213
+ current_time = nil
214
+
215
+ output.each_line do |line|
216
+ line = line.chomp
217
+ if line.match?(/\A[0-9a-f]{40}\s/)
218
+ parts = line.split
219
+ current_line = parts[2]&.to_i
220
+ current_author = nil
221
+ current_email = nil
222
+ current_time = nil
223
+ elsif line.start_with?("author ")
224
+ current_author = line.sub("author ", "")
225
+ elsif line.start_with?("author-mail ")
226
+ current_email = line.sub("author-mail ", "").delete("<>").strip.downcase
227
+ elsif line.start_with?("author-time ")
228
+ current_time = line.sub("author-time ", "").to_i
229
+ elsif line.start_with?("\t") && current_line
230
+ # End of this blame entry — store it
231
+ date = current_time ? Time.at(current_time).to_date : nil
232
+ result[current_line] = {
233
+ author: current_author,
234
+ email: current_email,
235
+ date: date
236
+ }
237
+ end
238
+ end
239
+
240
+ result
241
+ end
242
+
243
+ def current_git_email
244
+ output, _status = Railbow::GitUtils.capture2("config", "user.email")
245
+ output.strip.downcase
246
+ end
247
+
248
+ def print_help
249
+ Railbow.print_logo
250
+ puts <<~HELP
251
+
252
+ Enhanced rails notes
253
+
254
+ \e[1mUsage:\e[0m
255
+ [RBW_*=value ...] rails notes
256
+
257
+ \e[1mOptions:\e[0m
258
+ RBW_GIT=<options> Git integration (comma-separated):
259
+ author — show all authors (same as author:all)
260
+ author:all — show author + date on each annotation
261
+ author:me — highlight your own annotations
262
+
263
+ RBW_SINCE=<period> Filter annotations by blame date (default: all)
264
+ Values: all, 2mo, 1w, 30d, 1y, etc.
265
+ Units: d (days), w (weeks), mo/m (months), y (years)
266
+
267
+ RBW_SORT=<mode> Sort order (default: file)
268
+ file — group by file (default Rails order)
269
+ date — sort by blame date (newest first)
270
+
271
+ RBW_PLAIN=1 Disable Railbow formatting (plain Rails output)
272
+
273
+ RBW_HELP=1 Show this help message
274
+
275
+ \e[2mAuto-disabled when piped, in CI, or when called by an LLM agent.\e[0m
276
+
277
+ \e[1mExamples:\e[0m
278
+ rails notes
279
+ RBW_GIT=author rails notes
280
+ RBW_GIT=author:me rails notes
281
+ RBW_SINCE=1mo RBW_GIT=author rails notes
282
+ RBW_SORT=date RBW_GIT=author rails notes
283
+ RBW_SINCE=2w RBW_GIT=author:me RBW_SORT=date rails notes
284
+
285
+ HELP
286
+ end
287
+
288
+ def word_wrap_simple(text, max_width)
289
+ lines = []
290
+ current = +""
291
+ current_width = 0
292
+
293
+ text.split(/(\s+)/).each do |token|
294
+ if current_width + token.length <= max_width
295
+ current << token
296
+ current_width += token.length
297
+ elsif current_width.zero?
298
+ lines << token
299
+ else
300
+ lines << current.rstrip
301
+ token = token.lstrip
302
+ current = +token
303
+ current_width = token.length
304
+ end
305
+ end
306
+
307
+ lines << current.rstrip unless current.strip.empty?
308
+ lines
309
+ end
310
+ end
311
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require_relative "config"
5
+ require_relative "name_formatter"
6
+ require_relative "name_collision_resolver"
7
+
8
+ module Railbow
9
+ module Params
10
+ module_function
11
+
12
+ # Parse compound ENV value: "author:me,diff,base:develop"
13
+ # → { "author" => "me", "diff" => true, "base" => "develop" }
14
+ # Repeated keys collect into arrays: "hide:date,hide:author"
15
+ # → { "hide" => ["date", "author"] }
16
+ def parse_compound(value)
17
+ return {} if value.nil? || value.strip.empty?
18
+
19
+ result = {}
20
+ value.strip.split(",").each do |token|
21
+ token = token.strip
22
+ next if token.empty?
23
+
24
+ key, val = token.split(":", 2)
25
+ key = key.strip
26
+ val = val ? val.strip : true
27
+
28
+ result[key] = if result.key?(key)
29
+ Array(result[key]) << val
30
+ else
31
+ val
32
+ end
33
+ end
34
+ result
35
+ end
36
+
37
+ def truthy?(value)
38
+ %w[1 true yes on].include?(value.to_s.strip.downcase)
39
+ end
40
+
41
+ # Parse SINCE value like "2mo", "30d", "1w", "1y" into a Date cutoff.
42
+ # Returns nil for "all" or unrecognized values.
43
+ def parse_since(value, context: nil)
44
+ return nil if value.nil? || value.strip.downcase == "all"
45
+
46
+ match = value.strip.match(/^(\d+)(d|w|mo|m|y)$/i)
47
+ unless match
48
+ label = context ? " for #{context}" : ""
49
+ warn " Warning: unrecognized RBW_SINCE=#{value}#{label}, showing all"
50
+ return nil
51
+ end
52
+
53
+ amount = match[1].to_i
54
+ unit = match[2].downcase
55
+
56
+ case unit
57
+ when "d" then Date.today - amount
58
+ when "w" then Date.today - (amount * 7)
59
+ when "mo", "m" then Date.today.prev_month(amount)
60
+ when "y" then Date.today.prev_year(amount)
61
+ end
62
+ end
63
+
64
+ # --- Global accessors ---
65
+
66
+ def help?
67
+ truthy?(ENV["RBW_HELP"])
68
+ end
69
+
70
+ def plain?
71
+ truthy?(ENV["RBW_PLAIN"])
72
+ end
73
+
74
+ def since
75
+ (ENV["RBW_SINCE"] || Config.load["since"] || "all").strip.downcase
76
+ end
77
+
78
+ def sort
79
+ (ENV["RBW_SORT"] || Config.load["sort"] || "file").strip.downcase
80
+ end
81
+
82
+ # --- Compound: RBW_COMPACT ---
83
+
84
+ def compact
85
+ parse_compound(ENV["RBW_COMPACT"] || Config.load["compact"])
86
+ end
87
+
88
+ def compact_oneline?
89
+ compact["oneline"] == true
90
+ end
91
+
92
+ def compact_strip_format?
93
+ compact["strip-format"] == true
94
+ end
95
+
96
+ def compact_dense?
97
+ compact["dense"] == true
98
+ end
99
+
100
+ def compact_noheader?
101
+ compact["noheader"] == true
102
+ end
103
+
104
+ def compact_maxw
105
+ val = compact["maxw"]
106
+ val.is_a?(String) ? val.to_i : nil
107
+ end
108
+
109
+ def compact_hidden_columns
110
+ val = compact["hide"]
111
+ return [] if val.nil?
112
+ Array(val)
113
+ end
114
+
115
+ def compact_options
116
+ c = compact
117
+ maxw_val = c["maxw"]
118
+ hide_val = c["hide"]
119
+ {
120
+ oneline: c["oneline"] == true,
121
+ dense: c["dense"] == true,
122
+ noheader: c["noheader"] == true,
123
+ maxw: maxw_val.is_a?(String) ? maxw_val.to_i : nil,
124
+ hidden_columns: hide_val.nil? ? [] : Array(hide_val)
125
+ }
126
+ end
127
+
128
+ def verb
129
+ ENV["RBW_VERB"] || Config.load["verb"]
130
+ end
131
+
132
+ # --- Compound: RBW_GIT ---
133
+
134
+ def git
135
+ parse_compound(ENV["RBW_GIT"] || Config.load["git"])
136
+ end
137
+
138
+ def git_author
139
+ val = git["author"]
140
+ return "off" if val.nil?
141
+ (val == true) ? "all" : val.strip.downcase
142
+ end
143
+
144
+ def author_format
145
+ ENV["RBW_AUTHOR_FORMAT"] || Config.load["author_format"] || "short"
146
+ end
147
+
148
+ # Format an author name using NameFormatter.
149
+ # Accepts preset names ("initials", "first_name", etc.) or
150
+ # custom patterns ("FF L", "FFFF L.", "LLLL, FFFF").
151
+ def format_author(name)
152
+ NameFormatter.format(name, author_format)
153
+ end
154
+
155
+ # Format a batch of author names with collision resolution.
156
+ # Returns Hash{String => String} mapping raw names to disambiguated formatted names.
157
+ def format_authors(names)
158
+ NameCollisionResolver.resolve(names, author_format)
159
+ end
160
+
161
+ def git_diff?
162
+ git["diff"] == true
163
+ end
164
+
165
+ def git_base
166
+ val = git["base"]
167
+ val.is_a?(String) ? val : ""
168
+ end
169
+
170
+ def git_mask
171
+ val = git["mask"]
172
+ val.is_a?(String) ? val : ""
173
+ end
174
+
175
+ # Built-in regex for extracting ticket/task identifiers from branch names.
176
+ # Matches patterns like: ws-123, 123, abc123, ab-123-456, feat/ws-1234, feat/123-some-feature
177
+ TICKET_RE = /(?:^|\/)([a-z]{1,5}-?\d+(?:-\d+)*|\d+(?:-\d+)*)/i
178
+
179
+ # Extract a ticket identifier from a branch name.
180
+ # Returns the matched identifier or the original branch name if no match.
181
+ def extract_branch_ticket(branch)
182
+ m = branch.match(TICKET_RE)
183
+ m ? m[1] : branch
184
+ end
185
+
186
+ # --- RBW_DATE ---
187
+
188
+ def date_format
189
+ val = ENV["RBW_DATE"] || Config.load["date"]
190
+ return "full" if val.nil? || val.to_s.strip.empty?
191
+
192
+ val.to_s.strip
193
+ end
194
+
195
+ # --- Compound: RBW_VIEW ---
196
+
197
+ def view
198
+ parse_compound(ENV["RBW_VIEW"] || Config.load["view"])
199
+ end
200
+
201
+ def view_calendar?
202
+ view["calendar"] == true
203
+ end
204
+
205
+ def view_tables?
206
+ val = view["tables"]
207
+ if val == "nowrap"
208
+ warn " Warning: RBW_VIEW=tables:nowrap is deprecated. Use RBW_COMPACT=oneline instead."
209
+ end
210
+ !val.nil?
211
+ end
212
+
213
+ # --- Compound: RBW_CALENDAR ---
214
+
215
+ def calendar
216
+ parse_compound(ENV["RBW_CALENDAR"] || Config.load["calendar"])
217
+ end
218
+
219
+ def calendar_wticks?
220
+ return false unless view_calendar?
221
+
222
+ calendar["wticks"] == true
223
+ end
224
+
225
+ def calendar_label
226
+ calendar["label"] || "%b %Y W%V"
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Railbow
6
+ class Railtie < Rails::Railtie
7
+ # Initialize after ActiveRecord is loaded
8
+ initializer "railbow.enhance_migrations" do
9
+ ActiveSupport.on_load(:active_record) do
10
+ require_relative "migration_formatter"
11
+
12
+ # Prepend our formatter module to ActiveRecord::Migration
13
+ ActiveRecord::Migration.prepend(Railbow::MigrationFormatter)
14
+ end
15
+ end
16
+
17
+ # Enhance routes output with colors
18
+ initializer "railbow.enhance_routes", after: :load_config_initializers do
19
+ require "action_dispatch/routing/inspector"
20
+ require_relative "routes_formatter"
21
+ ActionDispatch::Routing::ConsoleFormatter::Sheet.prepend(Railbow::RoutesFormatter)
22
+ end
23
+
24
+ # Enhance `rails about` output
25
+ initializer "railbow.enhance_about", after: :load_config_initializers do
26
+ require_relative "about_formatter"
27
+ Rails::Info.singleton_class.prepend(Railbow::AboutFormatter)
28
+ end
29
+
30
+ # Enhance `rails notes` output
31
+ initializer "railbow.enhance_notes", after: :load_config_initializers do
32
+ require "rails/source_annotation_extractor"
33
+ require_relative "notes_formatter"
34
+ Rails::SourceAnnotationExtractor.prepend(Railbow::NotesFormatter)
35
+ end
36
+
37
+ # Load custom Rake tasks
38
+ rake_tasks do
39
+ load "railbow/tasks/migrate_status.rake"
40
+ load "railbow/tasks/stats.rake"
41
+ load "railbow/tasks/init.rake"
42
+ end
43
+ end
44
+ end