table_tennis 0.0.5 → 0.0.6

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: '009a0c9bbb140d308d55441602f96a534e51d989259c70b2e3cc2661213252cb'
4
- data.tar.gz: 6422661f90e1e6e15ddb67c0fff92d6d1c1f3d3454e95a57ae79652d83b981ca
3
+ metadata.gz: b141b15004b90e5acaf6f4ed92d7f71a49fbb8376875fd50cf5ed1d8e555c365
4
+ data.tar.gz: f9dd1e36fa97767addcce2abbfca0eb721137d6c87f0556f0ed79ca8677fa15c
5
5
  SHA512:
6
- metadata.gz: af82ae82579222df273e5244c2dde9137547cce527b4c5e4b900feeb7129beb7d9b5d9ea80ae849f62ac79befd14c38be0f8e8c311a651f8dc63660b1953190a
7
- data.tar.gz: 7eae197b20fe45f0b65f9987097e38233dac3ea113fcabbfd27011340a18910f9c8d701a3352cb482819f266391e98a0daaeec0857e966982e6d6ffcf2aa2121
6
+ metadata.gz: eba4146e9b2953bef53d958b7d3bab8d65f25be9c0011309cc511f3ecc254385dc3193f9f84c12c6d768010a9d7b4ae35d49530c6a2756906eb50aeb756d72db
7
+ data.tar.gz: f7d7262f3afb54a563a73d5e7279052e6dd2f4fb39a4e916994ee35f966b1f36ff7a45e22eb18619db43fe6cc516f0f34a714bb17614fcd03d1562a20fcd0ede
@@ -2,9 +2,8 @@ name: test
2
2
 
3
3
  on:
4
4
  push:
5
- paths-ignore:
6
- - README.md
7
- - 'screenshots/**'
5
+ branches: [main]
6
+ paths-ignore: [README.md, "screenshots/**"]
8
7
  pull_request:
9
8
  workflow_dispatch:
10
9
 
data/.rubocop.yml CHANGED
@@ -21,6 +21,7 @@ AllCops:
21
21
 
22
22
  # we like these, don't remove
23
23
  Bundler/OrderedGems: { Enabled: true } # sort gems in gemfile
24
+ Bundler/GemVersion: { Enabled: true } # make sure we have versions
24
25
  Layout/EmptyLineBetweenDefs: { AllowAdjacentOneLineDefs: true }
25
26
  Lint/NonLocalExitFromIterator: { Enabled: false }
26
27
  Lint/RedundantDirGlobSort: { Enabled: true } # glob is already sorted
data/Gemfile CHANGED
@@ -2,15 +2,15 @@ source "https://rubygems.org"
2
2
  gemspec
3
3
 
4
4
  group :development, :test do
5
- gem "amazing_print"
6
- gem "image_optim"
7
- gem "image_optim_pack"
8
- gem "minitest"
9
- gem "minitest-hooks"
10
- gem "mocha"
11
- gem "ostruct" # required for Ruby 3.5+
12
- gem "pry"
13
- gem "rake"
14
- gem "simplecov", require: false
15
- gem "standard", require: false, platform: :mri
5
+ gem "amazing_print", "~> 1.7"
6
+ gem "image_optim", "~> 0.31"
7
+ gem "image_optim_pack", "~> 0.12"
8
+ gem "minitest", "~> 5.25"
9
+ gem "minitest-hooks", "~> 1.5"
10
+ gem "mocha", "~> 2.7"
11
+ gem "ostruct", "~> 0.6" # required for Ruby 3.5+
12
+ gem "pry", "~> 0.15"
13
+ gem "rake", "~> 13.2"
14
+ gem "simplecov", "~> 0.22", require: false
15
+ gem "standard", "~> 1.49", require: false, platform: :mri
16
16
  end
data/README.md CHANGED
@@ -67,25 +67,34 @@ options = {
67
67
  }
68
68
  ```
69
69
 
70
+ #### Popular Options
71
+
70
72
  | option | default | details |
71
73
  | ------ | ------- | ------- |
72
74
  | `color_scales` | ─ | Color code a column of floats, similar to the "conditional formatting" feature in Google Sheets. See [docs below](#color-scales). |
73
- | `color` | `nil` | Are ANSI colors enabled? Specify `true` or `false`, or leave it as nil to autodetect. Autodetect will turn on color unless redirecting to a file. When using autodetect, you can force it on by setting `ENV["FORCE_COLOR"]`, or off with `ENV["NO_COLOR"]`. |
74
- | `columns` | `nil` | Manually set which columns to include. Leave unset to show all columns.
75
- | `delims` | true | Format ints & floats with comma delimiter, like 123,456. |
75
+ | `columns` | | Manually set which columns to include. Leave unset to show all columns.
76
+ | `headers` | | Specify some or all column headers. For example, `{user_id: "Customer"}`. When unset, headers are inferred. |
77
+ | `mark` | | `mark` is a way to highlight specific columns with a nice color. For example, use `mark: ->(row) { row[:planet] == "tatooine" }` to highlight those rows. Your lambda can also return a specific bg color or Paint color array.
78
+ | `row_numbers` | `false` | Show row numbers in the table. |
79
+ | `search` | ─ | String/regex to highlight in output. |
80
+ | `separators` | `true` | Include column and header separators in output. |
81
+ | `title` | ─ | Add a title line to the table. |
82
+ | `titleize` | ─ | Titleize column headers, so `person_id` becomes `Person`. |
83
+ | `zebra` | `false` | Turn on zebra stripes. |
84
+
85
+ #### More Advanced Options
86
+
87
+ | option | default | details |
88
+ | ------ | ------- | ------- |
89
+ | `coerce` | `true` | if true, try to coerce strings into numbers where possible so we can format with `digits`. You may want to disable this if you already have nicely formatted ints/floats and you don't want TableTennis to mess with them. |
90
+ | `color` | ─ | Are ANSI colors enabled? Specify `true` or `false`, or leave it as nil to autodetect. Autodetect will turn on color unless redirecting to a file. When using autodetect, you can force it on by setting `ENV["FORCE_COLOR"]`, or off with `ENV["NO_COLOR"]`. |
91
+ | `delims` | `true` | Format ints & floats with comma delimiter, like 123,456. |
76
92
  | `digits` | `3` | Format floats to this number of digits. TableTennis will look for either `Float` cells or string floats. |
77
93
  | `layout` | `true` | This controls column widths. Leave unset or use `true` for autolayout. Autolayout will shrink the table to fit inside the terminal. `false` turns off layout and columns will be full width. Use an int to fix all columns to a certain width, or a hash to just set a few. |
78
- | `mark` | ─ | `mark` is a way to highlight specific columns with a nice color. For example, use `mark: ->(row) { row[:planet] == "tatooine" }` to highlight those rows. Your lambda can also return a specific color if you want.
79
94
  | `placeholder` | `"—"` | Put this into empty cells. |
80
- | `row_numbers` | `false` | Show row numbers in the table. |
81
95
  | `save` | ─ | If you set this to a file path, TableTennis will save your table as a CSV file too. Useful if you want to do something else with the data. |
82
- | `search` | ─ | string/regex to highlight in output |
83
- | `separators` | `true` | Include column and header separators in output. |
84
96
  | `strftime` | see → | strftime string for formatting Date/Time objects. The default is `"%Y-%m-%d"`, which looks like `2025-04-21` |
85
- | `theme` | nil | When unset, will be autodetected based on terminal background color. If autodetect fails the theme defaults to :dark. You can also manually specify `:dark`, `:light` or `:ansi`. If colors are turned off this setting has no effect.|
86
- | `title` | ─ | Add a title line to the table. |
87
- | `titleize` | ─ | Titleize column names, so `person_id` becomes `Person`. |
88
- | `zebra` | `false` | Turn on zebra stripes. |
97
+ | `theme` | | When unset, will be autodetected based on terminal background color. If autodetect fails the theme defaults to :dark. You can also manually specify `:dark`, `:light` or `:ansi`. If colors are turned off this setting has no effect.|
89
98
 
90
99
  ### Color Scales
91
100
 
@@ -163,6 +172,13 @@ We love CSV tools and use them all the time! Here are a few that we rely on:
163
172
 
164
173
  ### Changelog
165
174
 
175
+ #### 0.0.6 (May '25)
176
+
177
+ - added `coerce:` option to disable string => numeric coercion
178
+ - added `headers:` option to manually set column headers
179
+ - fixed some issues related to links, including `save:`
180
+ - added MagicOptions
181
+
166
182
  #### 0.0.5 (April '25)
167
183
 
168
184
  - Support for markdown style links in the cells
data/justfile CHANGED
@@ -22,6 +22,7 @@ gem-local:
22
22
 
23
23
  # this will tag, build and push to rubygems
24
24
  gem-push: check
25
+ @if rg -g '!justfile' "\bREMIND\b" ; then just _fatal "REMIND found, bailing" ; fi
25
26
  @just _banner rake release...
26
27
  rake release
27
28
 
@@ -10,12 +10,12 @@ module TableTennis
10
10
  # c is the column index
11
11
  attr_reader :name, :data, :c
12
12
  attr_accessor :header, :width
13
- def_delegators :data, *%i[rows]
13
+ def_delegators :data, *%i[config rows]
14
14
 
15
15
  def initialize(data, name, c)
16
16
  @name, @data, @c = name, data, c
17
- @header = name.to_s
18
- if data&.config&.titleize?
17
+ @header = config&.headers&.dig(name) || name.to_s
18
+ if config&.titleize?
19
19
  @header = Util::Strings.titleize(@header)
20
20
  end
21
21
  end
@@ -3,15 +3,17 @@ module TableTennis
3
3
  attr_accessor :defaults
4
4
  end
5
5
 
6
- # Store the table configuration options, with lots of validation.
7
- class Config
6
+ # Table configuration options, with schema validation courtesy of MagicOptions.
7
+ class Config < Util::MagicOptions
8
8
  OPTIONS = {
9
+ coerce: true, # if true, we try to coerce strings into numbers
9
10
  color_scales: nil, # columns => color scale
10
11
  color: nil, # true/false/nil (detect)
11
12
  columns: nil, # array of symbols, or inferred from rows
12
13
  debug: false, # true for debug output
13
14
  delims: true, # true for numeric delimeters
14
15
  digits: 3, # format floats
16
+ headers: nil, # columns => header strings, or inferred from columns
15
17
  layout: true, # true/false/int or hash of columns -> width. true to infer
16
18
  mark: nil, # lambda returning boolean or symbol to mark rows in output
17
19
  placeholder: "—", # placeholder for empty cells. default is emdash
@@ -26,206 +28,94 @@ module TableTennis
26
28
  zebra: false, # turn on zebra stripes
27
29
  }.freeze
28
30
 
29
- def initialize(options = {}, &block)
30
- options = [OPTIONS, TableTennis.defaults, options].reduce { _1.merge(_2 || {}) }
31
- options[:color] = Config.detect_color? if options[:color].nil?
32
- options[:theme] = Config.detect_theme if options[:theme].nil?
33
- options[:debug] = true if ENV["TT_DEBUG"]
34
- options.each { self[_1] = _2 }
35
-
36
- yield self if block_given?
37
- end
38
-
39
- # readers
40
- attr_reader(*OPTIONS.keys)
41
-
42
- #
43
- # simple writers
44
- #
45
-
46
- {
31
+ SCHEMA = {
32
+ coerce: :bool,
33
+ color_scales: ->(value) do
34
+ if (error = Config.magic_validate(value, {Symbol => Symbol}))
35
+ error
36
+ elsif value.values.any? { !Util::Scale::SCALES.include?(_1) }
37
+ "values must be the name of a color scale"
38
+ end
39
+ end,
47
40
  color: :bool,
41
+ columns: :symbols,
48
42
  debug: :bool,
49
43
  delims: :bool,
50
- digits: :int,
44
+ digits: (0..10),
45
+ headers: {sym: :str},
46
+ layout: -> do
47
+ return if _1 == true || _1 == false || _1.is_a?(Integer)
48
+ Config.magic_validate(_1, {Symbol => Integer})
49
+ end,
51
50
  mark: :proc,
52
51
  placeholder: :str,
53
52
  row_numbers: :bool,
54
53
  save: :str,
54
+ search: -> do
55
+ if !(_1.is_a?(String) || _1.is_a?(Regexp))
56
+ "expected string/regex"
57
+ end
58
+ end,
55
59
  separators: :bool,
56
60
  strftime: :str,
61
+ theme: %i[dark light ansi],
57
62
  title: :str,
58
63
  titleize: :bool,
59
64
  zebra: :bool,
60
- }.each do |option, type|
61
- define_method(:"#{option}=") do |value|
62
- instance_variable_set(:"@#{option}", send(:"_#{type}", option, value))
63
- end
64
- alias_method(:"#{option}?", option) if type == :bool
65
- end
66
-
67
- #
68
- # helpers
69
- #
70
-
71
- # is this a dark terminal?
72
- def self.terminal_dark?
73
- if (bg = Util::Termbg.bg)
74
- Util::Colors.dark?(bg)
75
- end
76
- end
65
+ }
77
66
 
78
- def self.detect_color?
79
- return false if ENV["NO_COLOR"] || ENV["CI"]
80
- return true if ENV["FORCE_COLOR"] == "1"
81
- return false if !($stdout.tty? && $stderr.tty?)
82
- Paint.detect_mode > 0
83
- end
84
-
85
- def self.detect_theme
86
- case terminal_dark?
87
- when true, nil then :dark
88
- when false then :light
89
- end
67
+ def initialize(options = {}, &block)
68
+ # assemble from OPTIONS, defaults and options
69
+ options = [OPTIONS, TableTennis.defaults, options].reduce { _1.merge(_2 || {}) }
70
+ options[:color] = Config.detect_color? if options[:color].nil?
71
+ options[:debug] = true if ENV["TT_DEBUG"]
72
+ options[:theme] = Config.detect_theme if options[:theme].nil?
73
+ super(SCHEMA, options, &block)
90
74
  end
91
75
 
92
76
  #
93
- # complex writers
77
+ # override a few setters to coerce values
94
78
  #
95
79
 
96
80
  def color_scales=(value)
97
- case value
98
- when Array then value = value.to_h { [_1, :g] }
99
- when Symbol then value = {value => :g}
100
- end
101
- value.to_h { [_1, :g] } if value.is_a?(Array)
102
- @color_scales = validate(:color_scales, value) do
103
- if !value.is_a?(Hash)
104
- "expected hash"
105
- elsif value.keys.any? { !_1.is_a?(Symbol) }
106
- "keys must be symbols"
107
- elsif value.values.any? { !_1.is_a?(Symbol) }
108
- "values must be symbols"
109
- elsif value.values.any? { !Util::Scale::SCALES.include?(_1) }
110
- "values must be the name of a color scale"
111
- end
81
+ if value.is_a?(Array) || value.is_a?(Symbol)
82
+ value = Array(value).to_h { [_1, :g] }
112
83
  end
84
+ self[:color_scales] = value
113
85
  end
114
- alias_method(:"color_scale=", :"color_scales=")
115
86
 
116
- def columns=(value)
117
- @columns = validate(:columns, value) do
118
- if !(value.is_a?(Array) && !value.empty? && value.all? { _1.is_a?(Symbol) })
119
- "expected array of symbols"
120
- end
121
- end
87
+ def placeholder=(value)
88
+ value = "" if value.nil?
89
+ self[:placeholder] = value
122
90
  end
123
91
 
124
- def theme=(value)
125
- @theme = validate(:theme, value) do
126
- if !value.is_a?(Symbol)
127
- "expected symbol"
128
- elsif !Theme::THEMES.key?(value)
129
- "expected one of #{Theme::THEMES.keys.inspect}"
130
- end
131
- end
92
+ def title=(value)
93
+ value = value.to_s if value.is_a?(Symbol)
94
+ self[:title] = value
132
95
  end
133
96
 
134
- def search=(value)
135
- @search = validate(:search, value) do
136
- if !(value.is_a?(String) || value.is_a?(Regexp))
137
- "expected string/regex"
138
- end
139
- end
140
- end
141
-
142
- def layout=(value)
143
- @layout = validate(:layout, value) do
144
- next if [true, false].include?(value) || value.is_a?(Integer)
145
- if !value.is_a?(Hash)
146
- "expected boolean, int or hash"
147
- elsif value.keys.any? { !_1.is_a?(Symbol) }
148
- "keys must be symbols"
149
- elsif value.values.any? { !_1.is_a?(Integer) }
150
- "values must be ints"
151
- end
152
- end
153
- end
154
-
155
- def [](key)
156
- raise ArgumentError, "unknown TableTennis.#{key}" if !respond_to?(key)
157
- send(key)
158
- end
159
-
160
- def []=(key, value)
161
- raise ArgumentError, "unknown TableTennis.#{key}=" if !respond_to?(:"#{key}=")
162
- send(:"#{key}=", value)
163
- end
164
-
165
- def inspect
166
- options = to_h.map { "@#{_1}=#{_2.inspect}" }.join(", ")
167
- "#<Config #{options}>"
168
- end
169
-
170
- def to_h
171
- OPTIONS.keys.to_h { [_1, self[_1]] }.compact
172
- end
173
-
174
- protected
175
-
176
97
  #
177
- # validations
98
+ # helpers
178
99
  #
179
100
 
180
- def validate(option, value, &block)
181
- if value != nil && (error = yield)
182
- raise ArgumentError, "TableTennis.#{option} #{error}, got #{value.inspect}"
183
- end
184
- value
185
- end
186
-
187
- def _bool(option, value)
188
- value = case value
189
- when true, 1, "1", "true" then true
190
- when false, 0, "", "0", "false" then false
191
- else; value # this will turn into an error down below
192
- end
193
- validate(option, value) do
194
- "expected boolean" if ![true, false].include?(value)
195
- end
196
- end
197
-
198
- def _int(option, value)
199
- validate(option, value) do
200
- if !value.is_a?(Integer)
201
- "expected int"
202
- elsif value < 0
203
- "expected positive int"
204
- end
205
- end
206
- end
207
-
208
- def _proc(option, value)
209
- validate(option, value) do
210
- "expected proc" if !value.is_a?(Proc)
211
- end
101
+ def self.detect_color?
102
+ return false if ENV["NO_COLOR"] || ENV["CI"]
103
+ return true if ENV["FORCE_COLOR"] == "1"
104
+ return false if !($stdout.tty? && $stderr.tty?)
105
+ Paint.detect_mode > 0
212
106
  end
213
107
 
214
- def _str(option, value)
215
- case option
216
- when :placeholder
217
- value = "" if value.nil?
218
- when :title
219
- value = value.to_s if value.is_a?(Symbol)
220
- end
221
- validate(option, value) do
222
- "expected string" if !value.is_a?(String)
108
+ def self.detect_theme
109
+ case terminal_dark?
110
+ when true, nil then :dark
111
+ when false then :light
223
112
  end
224
113
  end
225
114
 
226
- def _sym(option, value)
227
- validate(option, value) do
228
- "expected symbol" if !value.is_a?(Symbol)
115
+ # is this a dark terminal?
116
+ def self.terminal_dark?
117
+ if (bg = Util::Termbg.bg)
118
+ Util::Colors.dark?(bg)
229
119
  end
230
120
  end
231
121
  end
@@ -27,7 +27,7 @@ module TableTennis
27
27
  str ||= fn_default(value) || config.placeholder
28
28
 
29
29
  # look for markdown-style links
30
- if (link = detect_link(str))
30
+ if (link = Util::Strings.hyperlink(str))
31
31
  str, data.links[[r, c]] = link
32
32
  end
33
33
 
@@ -46,16 +46,24 @@ module TableTennis
46
46
  @_memo_wise[__method__].tap { _1.clear if _1.length > 5000 }
47
47
 
48
48
  case value
49
- when String then fmt_number(to_f(value), digits: config.digits) if Util::Identify.number?(value)
50
- when Numeric then fmt_number(value, digits: config.digits)
49
+ when String
50
+ if config.coerce && Util::Identify.number?(value)
51
+ fmt_number(to_f(value), digits: config.digits)
52
+ end
53
+ when Numeric
54
+ fmt_number(value, digits: config.digits)
51
55
  end
52
56
  end
53
57
  memo_wise :fn_float
54
58
 
55
59
  def fn_int(value)
56
60
  case value
57
- when String then fmt_number(to_i(value)) if Util::Identify.int?(value)
58
- when Integer then fmt_number(value)
61
+ when String
62
+ if config.coerce && Util::Identify.int?(value)
63
+ fmt_number(to_i(value))
64
+ end
65
+ when Integer
66
+ fmt_number(value)
59
67
  end
60
68
  end
61
69
 
@@ -100,14 +108,6 @@ module TableTennis
100
108
  x
101
109
  end
102
110
 
103
- def detect_link(str)
104
- # fail fast, for speed
105
- return unless str.length >= 6 && str[0] == "["
106
- if str =~ /^\[([^\]]+)\]\(([^\)]+)\)$/
107
- [$1, $2]
108
- end
109
- end
110
-
111
111
  # str to_xxx that are resistant to commas
112
112
  def to_f(str) = str.delete(",").to_f
113
113
  def to_i(str) = str.delete(",").to_i
@@ -3,10 +3,6 @@
3
3
  #
4
4
  # puts TableTennis.new(array_of_hashes, options = {})
5
5
  #
6
- # See the README for details on options - _color_scales_, _color_, _columns_,
7
- # _debug_, _digits_, _layout_, _mark_, _placeholder_, _row_numbers_, _save_,
8
- # _search_, _strftime_, _theme_, _title_, _zebra_
9
- #
10
6
 
11
7
  module TableTennis
12
8
  # Public API for TableTennis.
@@ -41,7 +37,16 @@ module TableTennis
41
37
  def save(path)
42
38
  headers = column_names
43
39
  CSV.open(path, "wb", headers:, write_headers: true) do |csv|
44
- rows.each { csv << _1 }
40
+ rows.each do |row|
41
+ # strip hyperlinks
42
+ row = row.map do |value|
43
+ if value.is_a?(String) && (h = Util::Strings.hyperlink(value))
44
+ value = h[1]
45
+ end
46
+ value
47
+ end
48
+ csv << row
49
+ end
45
50
  end
46
51
  end
47
52
 
@@ -54,7 +59,7 @@ module TableTennis
54
59
 
55
60
  # we cnan do a bit more config checking now
56
61
  def sanity!
57
- %i[color_scales layout].each do |key|
62
+ %i[color_scales headers layout].each do |key|
58
63
  next if !config[key].is_a?(Hash)
59
64
  next if rows.empty? # ignore on empty data
60
65
  invalid = config[key].keys - data.column_names
@@ -71,9 +76,6 @@ module TableTennis
71
76
  #
72
77
  # puts TableTennis.new(array_of_hashes_or_records, options = {})
73
78
  #
74
- # See the README for details on options - _color_scales_, _color_,
75
- # _columns_, _debug_, _digits_, _layout_, _mark_, _placeholder_,
76
- # _row_numbers_, _save_, _search_, _strftime_, _theme_, _title_, _zebra_
77
79
  def new(*args, &block) = Table.new(*args, &block)
78
80
  end
79
81
  end
@@ -101,9 +101,18 @@ module TableTennis
101
101
  # use osc 8 to create a terminal hyperlink. underline too
102
102
  def link(str, link)
103
103
  linked = "#{OSC_8}#{link}#{ST}#{str}#{OSC_8}#{ST}"
104
- Paint[linked, :underline]
104
+ # underline if aren't on iterm
105
+ linked = Paint[linked, :underline] if term_program != :iterm
106
+ linked
105
107
  end
106
108
 
109
+ def term_program
110
+ if ENV["TERM_PROGRAM"] == "iTerm.app"
111
+ :iterm
112
+ end
113
+ end
114
+ memo_wise :term_program
115
+
107
116
  # for debugging, mostly
108
117
  def self.info
109
118
  sample = if !Config.detect_color?
@@ -0,0 +1,255 @@
1
+ #
2
+ # Helper class for validated option processing. This is used by Config but could probably be a
3
+ # custom gem at some point...
4
+ #
5
+ # MagicOptions is created with a `schema` defining a list of `attributes`. Each attribute has a
6
+ # `name` and a `type`. `options` is a hash of values that will be validated against the schema.
7
+ # MagicOptions adds getters and setters for each attribute, and also supports [] and []=. The
8
+ # setters perform validation and raise ArgumentError if something is awry. Because setters always
9
+ # validate, it is not possible to populate MagicOptions with invalid values.
10
+ #
11
+ # To use, subclass MagicOptions and construct with a schema:
12
+ #
13
+ # class Config < MagicOptions
14
+ # def initialize
15
+ # super(
16
+ # first_name: :str,
17
+ # colors: :strings,
18
+ # customer: :bool,
19
+ # age: (20..90)
20
+ # )
21
+ # end
22
+ # end
23
+ #
24
+ # Then assign values:
25
+ #
26
+ # config = Config.new
27
+ # config.colors = %w[red white blue]
28
+ # config.update!(first_name: "john", customer: false)
29
+ #
30
+ # Here are the supported attribute types:
31
+ #
32
+ # (1) A simple type like :bool, :int, :num, :float, :str or :sym.
33
+ # (2) An array type like :bools, :ints, :nums, :floats, :strs, or :syms.
34
+ # (3) A range, regexp or Class.
35
+ # (4) A lambda which should return an error string or nil.
36
+ # (5) An array of possible values (typically numbers, strings, or symbols). The
37
+ # value must be one of those possibilities.
38
+ # (6) A hash with one element { class => class }. This specifies the hash
39
+ # signature, and the value must be a hash where the keys and values are
40
+ # those classes.
41
+ #
42
+ # There is a bit of type coercion, but not much. For example, the string "true" or "1" will be
43
+ # coerced to true for boolean options. Integers can be used when the schema calls for floats.
44
+ #
45
+
46
+ module TableTennis
47
+ module Util
48
+ class MagicOptions
49
+ #
50
+ # public api (also see [] and []=)
51
+ #
52
+
53
+ attr_accessor :magic_attributes, :magic_values
54
+
55
+ def initialize(schema, options = {}, &block)
56
+ @magic_attributes, @magic_values = {}, {}
57
+
58
+ if self.class == MagicOptions # rubocop:disable Style/ClassEqualityComparison
59
+ raise ArgumentError, "MagicOptions is an abstract class"
60
+ end
61
+
62
+ schema.each { magic_add_attribute(_1, _2) }
63
+ update!(options) if options
64
+ yield self if block_given?
65
+ end
66
+
67
+ def update!(hash) = hash.each { send("#{_1}=", _2) }
68
+ def to_h = magic_values.dup
69
+
70
+ def inspect
71
+ values = magic_values.compact.map { "#{_1}=#{_2.inspect}" }.join(", ")
72
+ "#<#{self.class} #{values}>"
73
+ end
74
+
75
+ #
76
+ # magic_add_attribute
77
+ #
78
+
79
+ def magic_add_attribute(name, type)
80
+ # resolve :boolean to :bool, :int => Integer class, etc.
81
+ type = if type.is_a?(Hash)
82
+ type.to_h { [self.class.magic_resolve(_1), self.class.magic_resolve(_2)] }
83
+ else
84
+ self.class.magic_resolve(type)
85
+ end
86
+
87
+ # sanity check for schema errors
88
+ if (error = magic_sanity(name, type))
89
+ raise ArgumentError, "MagicOptions schema #{name.inspect} #{error}"
90
+ end
91
+
92
+ # all is well
93
+ magic_attributes[name] = type
94
+ if !respond_to?(name)
95
+ define_singleton_method(name) { self[name] }
96
+ end
97
+ if type == :bool && !respond_to?("#{name}?")
98
+ define_singleton_method("#{name}?") { !!self[name] }
99
+ end
100
+ if !respond_to?("#{name}=")
101
+ define_singleton_method("#{name}=") { |value| self[name] = value }
102
+ end
103
+ end
104
+
105
+ # sanity check a name/type from the schema
106
+ def magic_sanity(name, type)
107
+ if !name.is_a?(Symbol)
108
+ return "attribute names must be symbols"
109
+ end
110
+ if !name.to_s.match?(/\A[a-z_][0-9a-z_]*\z/i)
111
+ return "attribute names must be valid method names"
112
+ end
113
+
114
+ case type
115
+ when :bool, :bools, :floats, :ints, :nums, :strs, :syms, Class, Proc, Range, Regexp
116
+ return
117
+ when Array
118
+ "must be an array of possible values" if type.empty?
119
+ when Hash
120
+ valid = type.length == 1 && type.first.all? { _1 == :bool || _1.is_a?(Class) }
121
+ "must be { class => class }" if !valid
122
+ else
123
+ "unknown schema type #{type.inspect}"
124
+ end
125
+ end
126
+
127
+ #
128
+ # magic_get/set
129
+ #
130
+
131
+ def magic_get(name)
132
+ raise ArgumentError, "unknown #{self.class}.#{name}" if !magic_attributes.key?(name)
133
+ magic_values[name]
134
+ end
135
+
136
+ def magic_set(name, value)
137
+ raise ArgumentError, "unknown #{self.class}.#{name}=" if !magic_attributes.key?(name)
138
+ type = magic_attributes[name]
139
+ value = self.class.magic_coerce(value, type)
140
+ if !value.nil? && (error = MagicOptions.magic_validate(value, type))
141
+ if !type.is_a?(Proc)
142
+ error = "#{error}, got #{value.inspect}"
143
+ end
144
+ raise ArgumentError, "#{self.class}.#{name}= #{error}"
145
+ end
146
+ magic_values[name] = value
147
+ end
148
+
149
+ # these are part of the public api
150
+ alias_method :[], :magic_get
151
+ alias_method :[]=, :magic_set
152
+
153
+ #
154
+ # magic_validate and static helpers
155
+ #
156
+
157
+ def self.magic_validate(value, type)
158
+ case type
159
+ when Array
160
+ "expected one of #{type.inspect}" if !type.include?(value)
161
+ when Class, :bool
162
+ "expected #{magic_pretty(type)}" if !magic_is_a?(value, type)
163
+ when Hash
164
+ name_klass, value_klass = type.first
165
+ valid = value.is_a?(Hash) && value.all? { magic_is_a?(_1, name_klass) && magic_is_a?(_2, value_klass) }
166
+ "expected hash of #{magic_pretty(name_klass)} => #{magic_pretty(value_klass)}" if !valid
167
+ when Proc
168
+ ret = type.call(value)
169
+ case ret
170
+ when String, false, nil then ret
171
+ else
172
+ puts "warning: MagicOptions.proc should ONLY return error string or nil/false, not #{ret.inspect}"
173
+ ret.to_s
174
+ end
175
+ when Range
176
+ if !value.is_a?(Numeric) || !type.include?(value)
177
+ "expected to be in range #{type.inspect}"
178
+ end
179
+ when Regexp
180
+ if !value.is_a?(String) || !value.match?(type)
181
+ "expected to be a string matching #{type.inspect}"
182
+ end
183
+ when :bools, :floats, :ints, :nums, :strs, :syms
184
+ klass = magic_resolve(type.to_s[..-2].to_sym)
185
+ valid = value.is_a?(Array) && value.all? { magic_is_a?(_1, klass) }
186
+ "expected array of #{type}" if !valid
187
+ end
188
+ end
189
+
190
+ # coerce value into type. pretty conservative at the moment
191
+ def self.magic_coerce(value, type)
192
+ if type == :bool
193
+ case value
194
+ when true, 1, "1", "true" then value = true
195
+ when false, 0, "", "0", "false" then value = false
196
+ end
197
+ end
198
+ value
199
+ end
200
+
201
+ # like is_a?, but supports :bool and allows ints to be floats
202
+ def self.magic_is_a?(value, klass)
203
+ if klass == :bool
204
+ value == true || value == false
205
+ elsif klass == Float
206
+ value.is_a?(klass) || value.is_a?(Integer)
207
+ else
208
+ value.is_a?(klass)
209
+ end
210
+ end
211
+
212
+ MAGIC_ALIASES = {
213
+ boolean: :bool,
214
+ booleans: :bools,
215
+ bool: :bool,
216
+ bools: :bools,
217
+ float: Float,
218
+ floats: :floats,
219
+ int: Integer,
220
+ integer: Integer,
221
+ integers: :ints,
222
+ ints: :ints,
223
+ lambda: Proc,
224
+ num: Numeric,
225
+ number: Numeric,
226
+ numbers: :nums,
227
+ nums: :nums,
228
+ proc: Proc,
229
+ str: String,
230
+ string: String,
231
+ strings: :strs,
232
+ strs: :strs,
233
+ sym: Symbol,
234
+ symbol: Symbol,
235
+ symbols: :syms,
236
+ syms: :syms,
237
+ }
238
+
239
+ MAGIC_PRETTY = {
240
+ :bool => "boolean",
241
+ Float => "float",
242
+ Integer => "integer",
243
+ Numeric => "number",
244
+ String => "string",
245
+ Symbol => "symbol",
246
+ }
247
+
248
+ # pretty print a class (or :bool)
249
+ def self.magic_pretty(klass) = MAGIC_PRETTY[klass] || klass.to_s
250
+
251
+ # resolve :boolean to :bool, :int => Integer class, etc.
252
+ def self.magic_resolve(type) = MAGIC_ALIASES[type] || type
253
+ end
254
+ end
255
+ end
@@ -22,8 +22,10 @@ module TableTennis
22
22
  simple?(text) ? text.length : Unicode::DisplayWidth.of(text)
23
23
  end
24
24
 
25
- def hyperlink(value)
26
- if value =~ /^\[([^\]]*)\]\(([^\)]*)\)$/
25
+ def hyperlink(str)
26
+ # fail fast, for speed
27
+ return unless str.length >= 6 && str[0] == "["
28
+ if str =~ /^\[(.*)\]\((.*)\)$/
27
29
  [$1, $2]
28
30
  end
29
31
  end
@@ -1,3 +1,3 @@
1
1
  module TableTennis
2
- VERSION = "0.0.5"
2
+ VERSION = "0.0.6"
3
3
  end
data/lib/table_tennis.rb CHANGED
@@ -9,6 +9,7 @@ require "unicode/display_width"
9
9
 
10
10
  # mixins must be at top
11
11
  require "table_tennis/util/inspectable"
12
+ require "table_tennis/util/magic_options"
12
13
 
13
14
  require "table_tennis/column"
14
15
  require "table_tennis/config"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: table_tennis
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.5
4
+ version: 0.0.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adam Doppelt
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-04-28 00:00:00.000000000 Z
10
+ date: 2025-05-01 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: csv
@@ -108,6 +108,7 @@ files:
108
108
  - lib/table_tennis/util/colors.rb
109
109
  - lib/table_tennis/util/identify.rb
110
110
  - lib/table_tennis/util/inspectable.rb
111
+ - lib/table_tennis/util/magic_options.rb
111
112
  - lib/table_tennis/util/scale.rb
112
113
  - lib/table_tennis/util/strings.rb
113
114
  - lib/table_tennis/util/termbg.rb