railbow 0.6.0 → 0.7.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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "zlib"
4
+ require_relative "../clock"
4
5
  require_relative "../text_utils"
5
6
 
6
7
  module Railbow
@@ -34,6 +35,9 @@ module Railbow
34
35
 
35
36
  BRIGHT_WHITE = "\e[1;97m"
36
37
 
38
+ # Narrowest name a tagged cell will accept before the tags are dropped.
39
+ NAME_TAGS_FLOOR = 12
40
+
37
41
  def dim(str) = "#{DIM}#{str}#{RESET}"
38
42
  def green(str) = "#{GREEN}#{str}#{RESET}"
39
43
  def yellow(str) = "#{YELLOW}#{str}#{RESET}"
@@ -66,23 +70,20 @@ module Railbow
66
70
  table_names.map { |t| table_tag(t) }.join(" ")
67
71
  end
68
72
 
69
- # Re-fits a pre-formatted table_tags string within max_width,
70
- # showing full table names and "+N" for overflow.
71
- # Accepts the full formatted string (with ANSI) and splits it into segments.
73
+ # Re-fits a formatted table_tags string within max_width: as many whole
74
+ # tags as fit plus "+N" for the rest, then one truncated tag, then "+N".
72
75
  def table_tags_fitted(formatted_str, max_width)
73
76
  return formatted_str if formatted_str.nil? || formatted_str.empty?
74
77
 
75
- # Split into individual tag segments: each is "\e[38;5;NNNm● name\e[0m"
78
+ # Each tag is "\e[38;5;NNNm● name\e[0m"
76
79
  segments = formatted_str.scan(/\e\[38;5;\d+m● [^\e]+\e\[0m/)
77
80
  return formatted_str if segments.empty?
78
81
 
79
82
  total = segments.size
80
83
  plain_segments = segments.map { |s| strip_ansi(s) }
81
84
 
82
- # Try fitting all tags
83
85
  return formatted_str if display_width(plain_segments.join(" ")) <= max_width
84
86
 
85
- # Try fitting progressively fewer tags with +N suffix
86
87
  (total - 1).downto(1) do |count|
87
88
  remaining = total - count
88
89
  suffix = " +#{remaining}"
@@ -92,17 +93,13 @@ module Railbow
92
93
  end
93
94
  end
94
95
 
95
- # Try fitting a truncated first table name + overflow.
96
- # Need at least 7 chars: "● t… +N" (dot + color prefix use no visible width beyond the dot)
96
+ # A truncated first tag needs 7 columns at least: "● t… +N"
97
97
  if max_width >= 7
98
98
  suffix = (total > 1) ? " +#{total - 1}" : ""
99
- # Available width for "● name…" part
100
99
  avail = max_width - display_width(suffix)
101
- # "● " prefix = 2 chars visible, plus at least 1 char of name + ellipsis (1 char)
102
100
  if avail >= 4 # "● " (2) + at least 1 char + "…" (1)
103
101
  first_plain = plain_segments[0] # e.g. "● some_table"
104
102
  name_part = first_plain.sub(/^● /, "")
105
- # Truncate name to fit: avail - 2 ("● ") - 1 ("…") = chars for name
106
103
  name_max = avail - 2 - 1
107
104
  truncated_name = name_part[0, name_max]
108
105
  color_match = segments[0].match(/\e\[38;5;\d+m/)
@@ -111,10 +108,33 @@ module Railbow
111
108
  end
112
109
  end
113
110
 
114
- # Absolute fallback: just +N
115
111
  "+#{total}"
116
112
  end
117
113
 
114
+ # Re-fits a Migration Name cell whose tags (branch, landed) sit flush
115
+ # right. Cutting from the right would eat the tags and leave a bare
116
+ # ellipsis, so the name gives up the space instead. Below NAME_TAGS_FLOOR
117
+ # the tags go: the name is what the row is about.
118
+ def name_with_tags_fitted(cell, max_width)
119
+ str = cell.to_s
120
+ return truncate_ansi(str, max_width) if display_width(strip_ansi(str)) <= max_width
121
+
122
+ split = str.rindex(/\s{2,}\e\[/)
123
+ return truncate_ansi(str, max_width) unless split
124
+
125
+ # rindex stops at the last two spaces of the padding run, so the rest of
126
+ # it still hangs off the name and would be truncated in place of it.
127
+ name = str[0...split].rstrip
128
+ tags = str[split..].lstrip
129
+ tags_width = display_width(strip_ansi(tags))
130
+ name_room = max_width - tags_width - 2
131
+ return truncate_ansi(name, max_width) if name_room < NAME_TAGS_FLOOR
132
+
133
+ name = truncate_ansi(name, name_room)
134
+ padding = max_width - display_width(strip_ansi(name)) - tags_width
135
+ "#{name}#{" " * [padding, 2].max}#{tags}"
136
+ end
137
+
118
138
  def format_timing(seconds)
119
139
  milliseconds = (seconds * 1000).round(1)
120
140
 
@@ -141,16 +161,8 @@ module Railbow
141
161
 
142
162
  def format_date(timestamp, mode = "full")
143
163
  ts = timestamp.to_s
144
- return ts if ts.length != 14
145
-
146
- time = begin
147
- Time.new(
148
- ts[0..3].to_i, ts[4..5].to_i, ts[6..7].to_i,
149
- ts[8..9].to_i, ts[10..11].to_i, ts[12..13].to_i
150
- )
151
- rescue ArgumentError
152
- return ts
153
- end
164
+ time = Railbow::Clock.from_version(ts)
165
+ return ts unless time
154
166
 
155
167
  case mode
156
168
  when "full"
@@ -166,8 +178,6 @@ module Railbow
166
178
  end
167
179
  end
168
180
 
169
- private
170
-
171
181
  def format_relative_time_from(time)
172
182
  diff = Time.now - time
173
183
  return "just now" if diff < 0
@@ -188,8 +198,6 @@ module Railbow
188
198
  else "~#{years}y ago"
189
199
  end
190
200
  end
191
-
192
- public
193
201
  end
194
202
  end
195
203
  end
data/lib/railbow/init.rb CHANGED
@@ -20,7 +20,7 @@ module Railbow
20
20
  # Every key can also be overridden via RBW_* environment variables.
21
21
 
22
22
  # Time window for migrations to display (e.g. 30d, 2mo, 1y, all)
23
- since: "70d"
23
+ since: "90d"
24
24
 
25
25
  # Never show fewer than this many migrations, however old they are.
26
26
  # The window above is a soft limit: a database with five migrations shows
@@ -37,7 +37,7 @@ module Railbow
37
37
  # diff - tag migrations by git origin branch
38
38
  # base:<branch> - base branch for diff (default: auto-detected)
39
39
  # mask:auto - auto-extract ticket id from branch name
40
- # mask:<re> - custom regex to extract branch label, e.g. mask:(WS-[^/]+)/
40
+ # mask:<re> - custom regex to extract branch label, e.g. mask:(PS-[^/]+)/
41
41
  git: "author:all,diff,mask:auto"
42
42
 
43
43
  # Author display format - preset or custom pattern (FF L, FFF LL, etc.):
@@ -60,6 +60,10 @@ module Railbow
60
60
  # Date format: full, rel, short, or custom(%b %d, %Y)
61
61
  # date: "full"
62
62
 
63
+ # Timezone dates are shown in: local (this machine), utc, or any zone
64
+ # Rails knows, e.g. America/New_York or "Eastern Time (US & Canada)"
65
+ # tz: "local"
66
+
63
67
  # Multi-database runs (comma-separated). Leave empty ("") to expand every
64
68
  # database into its own section, in database.yml order; a database with
65
69
  # nothing in the time window collapses to a one-line summary either way.
@@ -10,30 +10,12 @@ module Railbow
10
10
  def announce(message)
11
11
  return super if Railbow.plain?
12
12
 
13
- f = FORMATTER
14
- migration_name = self.class.name&.demodulize || "Migration"
15
-
16
- line = case message
17
- when /migrating/i
18
- f.cyan("#{f.emoji(:migrating)} #{migration_name}: migrating...")
19
- when /migrated\s*\((\d+\.\d+)s\)/i
20
- raw_seconds = Regexp.last_match(1).to_f
21
- formatted = f.format_timing(raw_seconds)
22
- f.green("#{f.emoji(:migrated)} #{migration_name}: migrated") + " (#{formatted} total)"
23
- when /reverting/i
24
- f.yellow("#{f.emoji(:reverting)} #{migration_name}: reverting...")
25
- when /reverted\s*\((\d+\.\d+)s\)/i
26
- raw_seconds = Regexp.last_match(1).to_f
27
- formatted = f.format_timing(raw_seconds)
28
- f.green("#{f.emoji(:reverted)} #{migration_name}: reverted") + " (#{formatted} total)"
29
- else
30
- "#{migration_name}: #{message}"
31
- end
32
-
33
- write ""
34
- write line
13
+ Railbow.guard(-> { super }) { railbow_announce(message) }
35
14
  end
36
15
 
16
+ # The yield runs the migration itself, so it stays outside the guard: a
17
+ # failing migration must raise as usual, and a formatting error must not
18
+ # re-run it through super. The fallback prints what Rails would have.
37
19
  def say_with_time(message)
38
20
  return super { yield } if Railbow.plain?
39
21
 
@@ -41,9 +23,16 @@ module Railbow
41
23
  result = nil
42
24
  time = Benchmark.measure { result = yield }
43
25
 
44
- timing = f.format_timing(time.real)
45
- write " #{f.green(f.emoji(:check))} #{message} → #{timing}"
46
- say("#{result} rows", :subitem) if result.is_a?(Integer)
26
+ plain = lambda do
27
+ say(message)
28
+ say("%.4fs" % time.real, :subitem)
29
+ say("#{result} rows", :subitem) if result.is_a?(Integer)
30
+ end
31
+ Railbow.guard(plain) do
32
+ timing = f.format_timing(time.real)
33
+ write " #{f.green(f.emoji(:check))} #{message} → #{timing}"
34
+ say("#{result} rows", :subitem) if result.is_a?(Integer)
35
+ end
47
36
 
48
37
  result
49
38
  end
@@ -60,5 +49,32 @@ module Railbow
60
49
 
61
50
  puts text
62
51
  end
52
+
53
+ private
54
+
55
+ def railbow_announce(message)
56
+ f = FORMATTER
57
+ migration_name = self.class.name&.demodulize || "Migration"
58
+
59
+ line = case message
60
+ when /migrating/i
61
+ f.cyan("#{f.emoji(:migrating)} #{migration_name}: migrating...")
62
+ when /migrated\s*\((\d+\.\d+)s\)/i
63
+ raw_seconds = Regexp.last_match(1).to_f
64
+ formatted = f.format_timing(raw_seconds)
65
+ f.green("#{f.emoji(:migrated)} #{migration_name}: migrated") + " (#{formatted} total)"
66
+ when /reverting/i
67
+ f.yellow("#{f.emoji(:reverting)} #{migration_name}: reverting...")
68
+ when /reverted\s*\((\d+\.\d+)s\)/i
69
+ raw_seconds = Regexp.last_match(1).to_f
70
+ formatted = f.format_timing(raw_seconds)
71
+ f.green("#{f.emoji(:reverted)} #{migration_name}: reverted") + " (#{formatted} total)"
72
+ else
73
+ "#{migration_name}: #{message}"
74
+ end
75
+
76
+ write ""
77
+ write line
78
+ end
63
79
  end
64
80
  end
@@ -4,7 +4,6 @@ require "active_support/inflector"
4
4
 
5
5
  module Railbow
6
6
  class MigrationParser
7
- # Methods that take a single table name as the first argument
8
7
  SINGLE_TABLE_METHODS = %w[
9
8
  create_table drop_table change_table
10
9
  add_column remove_column rename_column change_column
@@ -15,7 +14,6 @@ module Railbow
15
14
  add_timestamps remove_timestamps
16
15
  ].freeze
17
16
 
18
- # Methods that take two table names as arguments
19
17
  DUAL_TABLE_METHODS = %w[
20
18
  add_foreign_key remove_foreign_key
21
19
  ].freeze
@@ -25,7 +23,6 @@ module Railbow
25
23
  SINGLE_TABLE_PATTERN = /\b(?:#{(SINGLE_TABLE_METHODS + DUAL_TABLE_METHODS).join("|")})\s+[:"](\w+)/
26
24
  DUAL_TABLE_PATTERN = /\b(?:#{DUAL_TABLE_METHODS.join("|")})\s+[:"](\w+)["\s,]+[:"](\w+)/
27
25
 
28
- # SQL keywords followed by a table name
29
26
  SQL_TABLE_PATTERNS = [
30
27
  /\bUPDATE\s+["']?(\w+)["']?/i,
31
28
  /\bINSERT\s+INTO\s+["']?(\w+)["']?/i,
@@ -36,21 +33,19 @@ module Railbow
36
33
  /\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(\w+)["']?/i
37
34
  ].freeze
38
35
 
39
- # Helper/DSL calls that name their target table via a keyword argument, e.g.
40
- # `convert_to_monthly_partitions(table: :apples)` or
41
- # `remove_foreign_key :accounts, to_table: :owners`. Catches project-level
42
- # migration helpers regardless of the method name. Bare `from:`/`to:` are
43
- # deliberately not matched — `change_column_default :users, :status,
36
+ # A table named by keyword argument, which catches project-level helpers
37
+ # such as `convert_to_monthly_partitions(table: :apples)` whatever their
38
+ # name. Not bare `from:`/`to:`: `change_column_default :users, :status,
44
39
  # from: nil, to: "active"` would yield phantom tables.
45
40
  KEYWORD_TABLE_PATTERN = /\b(?:table|to_table|from_table):\s*[:"']?(\w+)/
46
41
 
47
42
  # Ruby `#` comments up to end of line, but not `#{}` interpolation.
48
43
  COMMENT_PATTERN = /#(?!\{).*/
49
44
 
50
- # AR class-level query/mutation methods that strongly signal a constant is a model.
51
- # Kept narrow on purpose — methods like `find`, `all`, `first`, `count`, `create`,
52
- # `new`, `transaction`, `order`, `includes` collide with stdlib/non-model constants
53
- # (e.g. Time.new, File.find, Array#first) and would produce false positives.
45
+ # AR class methods that strongly signal a constant is a model. Kept narrow:
46
+ # `find`, `all`, `first`, `count`, `create`, `new`, `transaction`, `order`,
47
+ # `includes` also exist on non-model constants (Time.new, File.find) and
48
+ # would produce false positives.
54
49
  MODEL_AR_METHODS = %w[
55
50
  find_each find_in_batches
56
51
  find_by find_by!
@@ -74,9 +69,8 @@ module Railbow
74
69
  def self.extract_tables_from_content(content)
75
70
  return [] if content.nil? || content.empty?
76
71
 
77
- # Comments would otherwise produce phantom tables — an explanatory
78
- # "# ALTER TABLE foo ..." or a commented-out create_table reads exactly
79
- # like the real thing to the patterns below.
72
+ # An explanatory "# ALTER TABLE foo ..." or a commented-out create_table
73
+ # reads exactly like the real thing to the patterns below.
80
74
  content = content.gsub(COMMENT_PATTERN, "")
81
75
 
82
76
  tables = []
@@ -89,10 +83,9 @@ module Railbow
89
83
  content.scan(KEYWORD_TABLE_PATTERN) { |match| tables << match[0] }
90
84
  tables.uniq!
91
85
 
92
- # Model-based detection is heuristic (a constant that happens to respond
93
- # to one of these methods isn't necessarily an AR model). Only use it to
94
- # supplement sparse DDL/SQL findings — skip once we already know of ≥2
95
- # tables, and cap the final total at 2.
86
+ # Model references are a guess (a constant answering these methods need
87
+ # not be a model), so they only fill in when DDL and SQL found fewer
88
+ # than 2 tables, and never take the total past 2.
96
89
  if tables.size < 2
97
90
  content.scan(MODEL_REFERENCE_PATTERN) do |match|
98
91
  break if tables.size >= 2
@@ -51,10 +51,24 @@ module Railbow
51
51
  def flush
52
52
  return if empty?
53
53
 
54
- Railbow::Status::Printer.new(@sections, skipped: @skipped).print
54
+ if Railbow.plain?
55
+ print_fallback
56
+ else
57
+ Railbow.guard(-> { print_fallback }) do
58
+ Railbow::Status::Printer.new(@sections, skipped: @skipped).print
59
+ end
60
+ end
55
61
  @sections = []
56
62
  @skipped = []
57
63
  end
64
+
65
+ private
66
+
67
+ # The stock Rails tables captured per database while their pools were
68
+ # still open; see MigrateStatusFormatter#migrate_status.
69
+ def print_fallback
70
+ @sections.each { |section| $stdout.write(section.fallback_output.to_s) }
71
+ end
58
72
  end
59
73
 
60
74
  module_function
@@ -3,13 +3,9 @@
3
3
  require_relative "name_formatter"
4
4
 
5
5
  module Railbow
6
- # Detects and resolves collisions when multiple different names produce the
7
- # same formatted output. Works by progressively expanding pattern tokens
8
- # (F → FF → FFF → FFFF, then L) until every distinct raw name maps to a
9
- # unique formatted string.
10
- #
11
- # Names that share the same first+last name but differ only in middle name
12
- # are treated as the same person and merged before collision detection.
6
+ # Makes distinct names format distinctly by widening pattern tokens (F to
7
+ # FFFF, then L) until no two collide. Names differing only in the middle
8
+ # name count as one person and share an output.
13
9
  #
14
10
  # NameCollisionResolver.resolve(["John Doe", "Jane Doe"], "F L")
15
11
  # # => {"John Doe" => "Jo D", "Jane Doe" => "Ja D"}
@@ -20,19 +16,16 @@ module Railbow
20
16
 
21
17
  # @param names [Array<String, nil>] raw author names (may contain duplicates/nils)
22
18
  # @param pattern [String] a NameFormatter pattern or preset name
23
- # @return [Hash{String => String}] raw_name → disambiguated formatted name
19
+ # @return [Hash{String => String}] raw name to disambiguated formatted name
24
20
  def resolve(names, pattern)
25
21
  pattern = NameFormatter::PRESETS.fetch(pattern, pattern)
26
22
  uniq_names = names.compact.reject(&:empty?).uniq
27
23
 
28
- # Merge names that are the same person (same first+last, differ only in middle)
29
24
  canonical, aliases = merge_same_person(uniq_names)
30
25
 
31
- # Format every canonical name with the base pattern
32
26
  result = {}
33
27
  canonical.each { |n| result[n] = NameFormatter.format(n, pattern) }
34
28
 
35
- # Find collision groups (different raw names → same formatted output)
36
29
  collisions = find_collisions(result)
37
30
  unless collisions.empty?
38
31
  tokens = parse_tokens(pattern)
@@ -41,16 +34,14 @@ module Railbow
41
34
  end
42
35
  end
43
36
 
44
- # Expand aliases: all variant spellings get the same formatted output
45
37
  aliases.each { |variant, canon| result[variant] = result[canon] }
46
38
 
47
39
  result
48
40
  end
49
41
 
50
- # --- private helpers ---
51
-
52
- # Group names by (first, last) and pick the shortest as canonical.
53
- # Returns [canonical_names, aliases_hash] where aliases maps variant → canonical.
42
+ # Groups names by first and last name; the shortest (no middle name) is
43
+ # canonical. Returns [canonical_names, aliases], aliases mapping each
44
+ # variant to its canonical name.
54
45
  def merge_same_person(names)
55
46
  groups = {}
56
47
  names.each do |name|
@@ -66,7 +57,6 @@ module Railbow
66
57
  canonical = []
67
58
  aliases = {}
68
59
  groups.each_value do |group|
69
- # Pick shortest name as canonical (the one without middle name)
70
60
  canon = group.min_by(&:length)
71
61
  canonical << canon
72
62
  group.each { |name| aliases[name] = canon unless name == canon }
@@ -81,14 +71,12 @@ module Railbow
81
71
  .transform_values { |pairs| pairs.map(&:first) }
82
72
  end
83
73
 
84
- # Resolve a single collision group by progressively expanding tokens.
85
- # Only names that still collide continue to be expanded; once a name
86
- # becomes unique it keeps its current (minimal) expansion.
74
+ # Widens tokens, all F tokens before L, only for names that still collide:
75
+ # a name that becomes unique keeps its shortest distinct form.
87
76
  def resolve_group(raw_names, base_pattern, base_tokens, result)
88
77
  remaining = raw_names.dup
89
78
  tokens = base_tokens.map(&:dup)
90
79
 
91
- # Expansion order: exhaust all F tokens first, then L tokens
92
80
  expansion_order = tokens.each_index.sort_by { |i| (tokens[i][:type] == "F") ? 0 : 1 }
93
81
 
94
82
  expansion_order.each do |ti|
@@ -102,15 +90,14 @@ module Railbow
102
90
 
103
91
  remaining.each { |n| result[n] = NameFormatter.format(n, current_pattern) }
104
92
 
105
- # Check collisions only within this group (not the full result),
106
- # so expansion from one group can't chase collisions with other groups
93
+ # Within this group only, so one group's expansion never chases
94
+ # collisions with another's.
107
95
  still_colliding = find_collisions(result.slice(*raw_names))
108
96
  if still_colliding.empty?
109
97
  remaining = []
110
98
  break
111
99
  end
112
100
 
113
- # Only keep expanding names that still collide within the group
114
101
  remaining = still_colliding.values.flatten
115
102
  end
116
103
 
@@ -119,7 +106,7 @@ module Railbow
119
106
 
120
107
  return if remaining.empty?
121
108
 
122
- # Last resort: numeric suffixes
109
+ # Fully expanded and still equal: number them.
123
110
  find_collisions(result.slice(*remaining)).each_value do |group|
124
111
  group[1..].each_with_index do |name, i|
125
112
  suffix = SUPERSCRIPTS[i] || (i + 2).to_s
@@ -128,7 +115,7 @@ module Railbow
128
115
  end
129
116
  end
130
117
 
131
- # Parse pattern into token descriptors: [{type:, length:, maxed:}, ...]
118
+ # [{type:, length:, maxed:}, ...] for each token in the pattern.
132
119
  def parse_tokens(pattern)
133
120
  tokens = []
134
121
  pattern.scan(NameFormatter::TOKEN_RE) do |match|
@@ -139,7 +126,7 @@ module Railbow
139
126
  tokens
140
127
  end
141
128
 
142
- # Rebuild a pattern string from token descriptors, preserving literals.
129
+ # The original pattern with its tokens resized, literals kept.
143
130
  def rebuild_pattern(tokens, original_pattern)
144
131
  ti = 0
145
132
  original_pattern.gsub(NameFormatter::TOKEN_RE) do
@@ -1,22 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railbow
4
- # Formats person names using a pattern language inspired by date-format strings.
4
+ # Formats person names with a pattern language modeled on date formats.
5
5
  #
6
- # Tokens (repeat the letter to control length; 4+ means full word):
7
- # F / FF / FFF / FFFF+ — first name (1 char, 2 chars, … , full)
8
- # L / LL / LLL / LLLL+ — last name
9
- # M / MM / MMM / MMMM+ — middle name(s)
6
+ # Tokens (the letter count is the length; 4 or more means the full word):
7
+ # F / FF / FFF / FFFF+ first name (1 char, 2 chars, 3 chars, full)
8
+ # L / LL / LLL / LLLL+ last name
9
+ # M / MM / MMM / MMMM+ middle name(s)
10
10
  #
11
11
  # Any other characters (spaces, dots, commas) are literals and pass through.
12
12
  #
13
13
  # Named presets:
14
- # "initials" → "F L"
15
- # "short" → "FF L"
16
- # "first_name" → "FFFF"
17
- # "last_name" → "LLLL"
18
- # "full_name" → "FFFF MMMM LLLL"
19
- # "full_name_short" → "FFFF L."
14
+ # "initials" => "F L"
15
+ # "short" => "FF L"
16
+ # "first_name" => "FFFF"
17
+ # "last_name" => "LLLL"
18
+ # "full_name" => "FFFF MMMM LLLL"
19
+ # "full_name_short" => "FFFF L."
20
20
  module NameFormatter
21
21
  PRESETS = {
22
22
  "initials" => "F L",
@@ -34,8 +34,7 @@ module Railbow
34
34
 
35
35
  module_function
36
36
 
37
- # Format a name according to a pattern or preset name.
38
- # Returns "" for nil/empty input.
37
+ # Formats a name by a pattern or preset name. "" for a nil or empty name.
39
38
  def format(name, pattern)
40
39
  return "" if name.nil? || name.empty?
41
40
 
@@ -59,14 +58,14 @@ module Railbow
59
58
  expanded.empty? ? EMPTY : expanded
60
59
  end
61
60
 
62
- # Remove sentinels and any adjacent non-letter literals (dots, commas, spaces)
61
+ # A missing part takes its adjacent literals with it, so "F L." on a
62
+ # one-word name gives no stray dot.
63
63
  result.gsub(/[^a-zA-Z\x00]*\x00[^a-zA-Z\x00]*/, " ")
64
64
  .gsub(/ +/, " ")
65
65
  .strip
66
66
  end
67
67
 
68
- # Truncate a word to `len` chars (4+ means full), preserving original case
69
- # for full words, capitalizing otherwise.
68
+ # A full word keeps its case; a cut one is capitalized.
70
69
  def truncate(word, len)
71
70
  return "" if word.nil? || word.empty?
72
71