class-metrix 0.1.2
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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +88 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE.txt +21 -0
- data/README.md +417 -0
- data/RELEASE_GUIDE.md +158 -0
- data/Rakefile +12 -0
- data/examples/README.md +155 -0
- data/examples/advanced/error_handling.rb +199 -0
- data/examples/advanced/hash_expansion.rb +180 -0
- data/examples/basic/01_simple_constants.rb +56 -0
- data/examples/basic/02_simple_methods.rb +99 -0
- data/examples/basic/03_multi_type_extraction.rb +116 -0
- data/examples/components/configurable_reports.rb +201 -0
- data/examples/csv_output_demo.rb +237 -0
- data/examples/real_world/microservices_audit.rb +312 -0
- data/lib/class_metrix/extractor.rb +121 -0
- data/lib/class_metrix/extractors/constants_extractor.rb +87 -0
- data/lib/class_metrix/extractors/methods_extractor.rb +87 -0
- data/lib/class_metrix/extractors/multi_type_extractor.rb +66 -0
- data/lib/class_metrix/formatters/base/base_component.rb +62 -0
- data/lib/class_metrix/formatters/base/base_formatter.rb +93 -0
- data/lib/class_metrix/formatters/components/footer_component.rb +67 -0
- data/lib/class_metrix/formatters/components/generic_header_component.rb +87 -0
- data/lib/class_metrix/formatters/components/header_component.rb +92 -0
- data/lib/class_metrix/formatters/components/missing_behaviors_component.rb +140 -0
- data/lib/class_metrix/formatters/components/table_component.rb +268 -0
- data/lib/class_metrix/formatters/csv_formatter.rb +98 -0
- data/lib/class_metrix/formatters/markdown_formatter.rb +184 -0
- data/lib/class_metrix/formatters/shared/csv_table_builder.rb +21 -0
- data/lib/class_metrix/formatters/shared/markdown_table_builder.rb +97 -0
- data/lib/class_metrix/formatters/shared/table_builder.rb +267 -0
- data/lib/class_metrix/formatters/shared/value_processor.rb +78 -0
- data/lib/class_metrix/processors/value_processor.rb +40 -0
- data/lib/class_metrix/utils/class_resolver.rb +20 -0
- data/lib/class_metrix/version.rb +5 -0
- data/lib/class_metrix.rb +12 -0
- data/sig/class/metrix.rbs +6 -0
- metadata +118 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../processors/value_processor"
|
|
4
|
+
|
|
5
|
+
module ClassMetrix
|
|
6
|
+
module Formatters
|
|
7
|
+
module Components
|
|
8
|
+
class TableComponent
|
|
9
|
+
def initialize(data, options = {})
|
|
10
|
+
@data = data
|
|
11
|
+
@options = options
|
|
12
|
+
@expand_hashes = options.fetch(:expand_hashes, false)
|
|
13
|
+
@table_style = options.fetch(:table_style, :standard) # :standard, :compact, :wide
|
|
14
|
+
@min_column_width = options.fetch(:min_column_width, 3)
|
|
15
|
+
@max_column_width = options.fetch(:max_column_width, 50)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def generate
|
|
19
|
+
return "" if @data[:headers].empty? || @data[:rows].empty?
|
|
20
|
+
|
|
21
|
+
if @expand_hashes
|
|
22
|
+
format_with_hash_expansion
|
|
23
|
+
else
|
|
24
|
+
format_simple_table
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def format_simple_table
|
|
31
|
+
headers = @data[:headers]
|
|
32
|
+
rows = @data[:rows]
|
|
33
|
+
|
|
34
|
+
processed_rows = process_simple_rows(rows)
|
|
35
|
+
build_table(headers, processed_rows)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def format_with_hash_expansion
|
|
39
|
+
headers = @data[:headers]
|
|
40
|
+
rows = @data[:rows]
|
|
41
|
+
|
|
42
|
+
expanded_rows = process_expanded_rows(rows, headers)
|
|
43
|
+
build_table(headers, expanded_rows)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def process_simple_rows(rows)
|
|
47
|
+
rows.map do |row|
|
|
48
|
+
processed_row = [row[0]] # Keep the behavior name as-is
|
|
49
|
+
row[1..].each do |value|
|
|
50
|
+
processed_row << ValueProcessor.process(value)
|
|
51
|
+
end
|
|
52
|
+
processed_row
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def process_expanded_rows(rows, headers)
|
|
57
|
+
expanded_rows = []
|
|
58
|
+
|
|
59
|
+
rows.each do |row|
|
|
60
|
+
if row_has_expandable_hash?(row, headers)
|
|
61
|
+
expanded_rows.concat(expand_row(row, headers))
|
|
62
|
+
else
|
|
63
|
+
expanded_rows << process_non_hash_row(row, headers)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
expanded_rows
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def row_has_expandable_hash?(row, headers)
|
|
71
|
+
has_type_column = headers.first == "Type"
|
|
72
|
+
value_start_index = has_type_column ? 2 : 1
|
|
73
|
+
row[value_start_index..].any? { |cell| cell.is_a?(Hash) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def process_non_hash_row(row, headers)
|
|
77
|
+
has_type_column = headers.first == "Type"
|
|
78
|
+
if has_type_column
|
|
79
|
+
[row[0], row[1]] + row[2..].map { |value| ValueProcessor.process(value) }
|
|
80
|
+
else
|
|
81
|
+
[row[0]] + row[1..].map { |value| ValueProcessor.process(value) }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_table(headers, rows)
|
|
86
|
+
col_widths = calculate_column_widths(headers, rows)
|
|
87
|
+
|
|
88
|
+
output = []
|
|
89
|
+
output << build_row(headers, col_widths)
|
|
90
|
+
output << build_separator(col_widths)
|
|
91
|
+
|
|
92
|
+
rows.each do |row|
|
|
93
|
+
output << build_row(row, col_widths)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
output.join("\n")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def expand_row(row, headers)
|
|
100
|
+
has_type_column = headers.first == "Type"
|
|
101
|
+
row_data = extract_row_data(row, has_type_column)
|
|
102
|
+
|
|
103
|
+
all_hash_keys = collect_hash_keys(row_data[:values])
|
|
104
|
+
return [row] if all_hash_keys.empty?
|
|
105
|
+
|
|
106
|
+
build_expanded_rows(row_data, all_hash_keys, has_type_column, row)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def extract_row_data(row, has_type_column)
|
|
110
|
+
if has_type_column
|
|
111
|
+
build_type_column_data(row)
|
|
112
|
+
else
|
|
113
|
+
build_standard_column_data(row)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def build_type_column_data(row)
|
|
118
|
+
{
|
|
119
|
+
type_value: row[0],
|
|
120
|
+
behavior_name: row[1],
|
|
121
|
+
values: row[2..]
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def build_standard_column_data(row)
|
|
126
|
+
{
|
|
127
|
+
behavior_name: row[0],
|
|
128
|
+
values: row[1..]
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def collect_hash_keys(values)
|
|
133
|
+
all_hash_keys = Set.new
|
|
134
|
+
values.each do |value|
|
|
135
|
+
all_hash_keys.merge(value.keys.map(&:to_s)) if value.is_a?(Hash)
|
|
136
|
+
end
|
|
137
|
+
all_hash_keys
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def build_expanded_rows(row_data, all_hash_keys, has_type_column, original_row)
|
|
141
|
+
expanded_rows = []
|
|
142
|
+
|
|
143
|
+
# Add main row
|
|
144
|
+
expanded_rows << build_main_expanded_row(row_data, has_type_column)
|
|
145
|
+
|
|
146
|
+
# Add key rows
|
|
147
|
+
all_hash_keys.to_a.sort.each do |key|
|
|
148
|
+
expanded_rows << build_key_row(key, row_data, has_type_column, original_row)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
expanded_rows
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def build_main_expanded_row(row_data, has_type_column)
|
|
155
|
+
processed_values = row_data[:values].map { |value| ValueProcessor.process(value) }
|
|
156
|
+
|
|
157
|
+
if has_type_column
|
|
158
|
+
[row_data[:type_value], row_data[:behavior_name]] + processed_values
|
|
159
|
+
else
|
|
160
|
+
[row_data[:behavior_name]] + processed_values
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def build_key_row(key, row_data, has_type_column, original_row)
|
|
165
|
+
path_name = ".#{key}"
|
|
166
|
+
key_values = process_key_values(key, row_data[:values], has_type_column, original_row)
|
|
167
|
+
|
|
168
|
+
if has_type_column
|
|
169
|
+
["-", path_name] + key_values
|
|
170
|
+
else
|
|
171
|
+
[path_name] + key_values
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def process_key_values(key, values, has_type_column, original_row)
|
|
176
|
+
values.map.with_index do |value, index|
|
|
177
|
+
if value.is_a?(Hash)
|
|
178
|
+
extract_hash_value(value, key)
|
|
179
|
+
else
|
|
180
|
+
handle_non_hash_value(original_row, index, has_type_column)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def extract_hash_value(hash, key)
|
|
186
|
+
has_key = hash.key?(key.to_sym) || hash.key?(key.to_s)
|
|
187
|
+
if has_key
|
|
188
|
+
hash_value = hash[key.to_sym] || hash[key.to_s]
|
|
189
|
+
ValueProcessor.process(hash_value)
|
|
190
|
+
else
|
|
191
|
+
"—" # Missing hash key
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def handle_non_hash_value(original_row, index, has_type_column)
|
|
196
|
+
original_value = original_row[has_type_column ? (index + 2) : (index + 1)]
|
|
197
|
+
if original_value.to_s.include?("🚫")
|
|
198
|
+
"🚫 Not defined"
|
|
199
|
+
else
|
|
200
|
+
"—" # Non-hash values don't have this key
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def calculate_column_widths(headers, rows)
|
|
205
|
+
col_count = headers.length
|
|
206
|
+
widths = initialize_column_widths(col_count, headers)
|
|
207
|
+
|
|
208
|
+
update_widths_from_rows(widths, rows, col_count)
|
|
209
|
+
apply_minimum_widths(widths)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def initialize_column_widths(col_count, headers)
|
|
213
|
+
widths = Array.new(col_count, 0)
|
|
214
|
+
headers.each_with_index do |header, i|
|
|
215
|
+
widths[i] = [widths[i], header.to_s.length].max
|
|
216
|
+
end
|
|
217
|
+
widths
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def update_widths_from_rows(widths, rows, col_count)
|
|
221
|
+
rows.each do |row|
|
|
222
|
+
row.each_with_index do |cell, i|
|
|
223
|
+
next if i >= col_count
|
|
224
|
+
|
|
225
|
+
cell_width = calculate_cell_width(cell)
|
|
226
|
+
widths[i] = [widths[i], cell_width].max
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def calculate_cell_width(cell)
|
|
232
|
+
cell_width = cell.to_s.length
|
|
233
|
+
# Apply max width limit for readability
|
|
234
|
+
@table_style == :compact ? [@max_column_width, cell_width].min : cell_width
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def apply_minimum_widths(widths)
|
|
238
|
+
widths.map { |w| [w, @min_column_width].max }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def build_row(cells, col_widths)
|
|
242
|
+
formatted_cells = format_cells(cells, col_widths)
|
|
243
|
+
"|#{formatted_cells.join("|")}|"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def format_cells(cells, col_widths)
|
|
247
|
+
cells.each_with_index.map do |cell, i|
|
|
248
|
+
width = col_widths[i] || 10
|
|
249
|
+
cell_str = format_cell_content(cell)
|
|
250
|
+
" #{cell_str.ljust(width)} "
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def format_cell_content(cell)
|
|
255
|
+
cell_str = cell.to_s
|
|
256
|
+
return cell_str unless @table_style == :compact && cell_str.length > @max_column_width
|
|
257
|
+
|
|
258
|
+
"#{cell_str[0...(@max_column_width - 3)]}..."
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def build_separator(col_widths)
|
|
262
|
+
separators = col_widths.map { |width| "-" * (width + 2) }
|
|
263
|
+
"|#{separators.join("|")}|"
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
require_relative "base/base_formatter"
|
|
5
|
+
require_relative "shared/csv_table_builder"
|
|
6
|
+
require_relative "components/generic_header_component"
|
|
7
|
+
|
|
8
|
+
module ClassMetrix
|
|
9
|
+
class CsvFormatter < Formatters::Base::BaseFormatter
|
|
10
|
+
def initialize(data, expand_hashes = false, options = {})
|
|
11
|
+
super
|
|
12
|
+
@table_builder = Formatters::Shared::CsvTableBuilder.new(data, expand_hashes, @options)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def format
|
|
16
|
+
return "" if @data[:headers].empty? || @data[:rows].empty?
|
|
17
|
+
|
|
18
|
+
output_lines = []
|
|
19
|
+
|
|
20
|
+
# Add CSV header comments
|
|
21
|
+
if @options.fetch(:show_metadata, true)
|
|
22
|
+
header_component = Formatters::Components::GenericHeaderComponent.new(@data, @options.merge(format: :csv))
|
|
23
|
+
header_lines = header_component.generate
|
|
24
|
+
output_lines.concat(header_lines)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Generate table data based on hash handling mode
|
|
28
|
+
table_data = determine_table_data
|
|
29
|
+
|
|
30
|
+
# Convert to CSV format
|
|
31
|
+
csv_output = render_csv_table(table_data)
|
|
32
|
+
output_lines.concat(csv_output)
|
|
33
|
+
|
|
34
|
+
output_lines.join("\n")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
protected
|
|
38
|
+
|
|
39
|
+
def default_options
|
|
40
|
+
super.merge({
|
|
41
|
+
separator: ",",
|
|
42
|
+
quote_char: '"',
|
|
43
|
+
flatten_hashes: false,
|
|
44
|
+
null_value: "",
|
|
45
|
+
comment_char: "#",
|
|
46
|
+
show_metadata: true
|
|
47
|
+
})
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def determine_table_data
|
|
53
|
+
if @options[:flatten_hashes]
|
|
54
|
+
@table_builder.build_flattened_table
|
|
55
|
+
elsif @expand_hashes
|
|
56
|
+
@table_builder.build_expanded_table
|
|
57
|
+
else
|
|
58
|
+
@table_builder.build_simple_table
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def render_csv_table(table_data)
|
|
63
|
+
headers = table_data[:headers]
|
|
64
|
+
rows = table_data[:rows]
|
|
65
|
+
|
|
66
|
+
return [] if headers.empty?
|
|
67
|
+
|
|
68
|
+
output = []
|
|
69
|
+
separator = @options.fetch(:separator, ",")
|
|
70
|
+
quote_char = @options.fetch(:quote_char, '"')
|
|
71
|
+
|
|
72
|
+
# Generate CSV rows
|
|
73
|
+
csv_rows = [headers] + rows
|
|
74
|
+
|
|
75
|
+
csv_rows.each do |row|
|
|
76
|
+
# Ensure all row values are strings and properly quoted
|
|
77
|
+
formatted_row = row.map { |cell| format_csv_cell(cell) }
|
|
78
|
+
csv_line = CSV.generate_line(formatted_row, col_sep: separator, quote_char: quote_char).chomp
|
|
79
|
+
output << csv_line
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
output
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def format_csv_cell(value)
|
|
86
|
+
case value
|
|
87
|
+
when nil
|
|
88
|
+
@options.fetch(:null_value, "")
|
|
89
|
+
when String
|
|
90
|
+
# Clean up emoji and special characters for CSV compatibility
|
|
91
|
+
clean_value = value.gsub(/[🚫⚠️✅❌]/, "").strip
|
|
92
|
+
clean_value.empty? ? @options.fetch(:null_value, "") : clean_value
|
|
93
|
+
else
|
|
94
|
+
value.to_s
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base/base_formatter"
|
|
4
|
+
require_relative "shared/markdown_table_builder"
|
|
5
|
+
require_relative "components/generic_header_component"
|
|
6
|
+
require_relative "components/missing_behaviors_component"
|
|
7
|
+
require_relative "components/footer_component"
|
|
8
|
+
|
|
9
|
+
module ClassMetrix
|
|
10
|
+
class MarkdownFormatter < Formatters::Base::BaseFormatter
|
|
11
|
+
def initialize(data, expand_hashes = false, options = {})
|
|
12
|
+
super
|
|
13
|
+
@table_builder = Formatters::Shared::MarkdownTableBuilder.new(data, expand_hashes, @options)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def format
|
|
17
|
+
return "" if @data[:headers].empty? || @data[:rows].empty?
|
|
18
|
+
|
|
19
|
+
output = []
|
|
20
|
+
|
|
21
|
+
# Add header sections (title, classes, extraction info)
|
|
22
|
+
if @options.fetch(:show_metadata, true)
|
|
23
|
+
header_component = Formatters::Components::GenericHeaderComponent.new(@data, @options.merge(format: :markdown))
|
|
24
|
+
header_output = header_component.generate
|
|
25
|
+
output.concat(header_output) unless header_output.empty?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Add main table
|
|
29
|
+
table_data = @expand_hashes ? @table_builder.build_expanded_table : @table_builder.build_simple_table
|
|
30
|
+
table_output = render_markdown_table(table_data)
|
|
31
|
+
unless table_output.empty?
|
|
32
|
+
# Join table rows with single newlines, then add as one section
|
|
33
|
+
output << table_output.join("\n")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Add missing behaviors summary
|
|
37
|
+
if @options.fetch(:show_missing_summary, false)
|
|
38
|
+
missing_component = Formatters::Components::MissingBehaviorsComponent.new(@data, @options)
|
|
39
|
+
missing_output = missing_component.generate
|
|
40
|
+
output.concat(missing_output) unless missing_output.empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Add footer
|
|
44
|
+
if @options.fetch(:show_footer, true)
|
|
45
|
+
footer_component = Formatters::Components::FooterComponent.new(@options)
|
|
46
|
+
footer_output = footer_component.generate
|
|
47
|
+
output.concat(footer_output) unless footer_output.empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Join with newlines, filtering out empty sections
|
|
51
|
+
output.reject { |section| section.nil? || section == "" }.join("\n\n")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
protected
|
|
55
|
+
|
|
56
|
+
def default_options
|
|
57
|
+
super.merge({
|
|
58
|
+
show_footer: true,
|
|
59
|
+
footer_style: :default,
|
|
60
|
+
show_timestamp: false,
|
|
61
|
+
show_classes: true,
|
|
62
|
+
show_extraction_info: true,
|
|
63
|
+
table_style: :standard,
|
|
64
|
+
summary_style: :grouped,
|
|
65
|
+
min_column_width: 3,
|
|
66
|
+
max_column_width: 20
|
|
67
|
+
})
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def render_markdown_table(table_data)
|
|
73
|
+
headers = table_data[:headers]
|
|
74
|
+
rows = table_data[:rows]
|
|
75
|
+
|
|
76
|
+
return [] if headers.empty? || rows.empty?
|
|
77
|
+
|
|
78
|
+
widths = calculate_column_widths(headers, rows)
|
|
79
|
+
|
|
80
|
+
output = []
|
|
81
|
+
output << build_header_row(headers, widths)
|
|
82
|
+
output << build_separator_row(widths)
|
|
83
|
+
output.concat(build_data_rows(rows, headers, widths))
|
|
84
|
+
|
|
85
|
+
output
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def build_header_row(headers, widths)
|
|
89
|
+
"| " + headers.map.with_index { |header, i| header.ljust(widths[i]) }.join(" | ") + " |"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def build_separator_row(widths)
|
|
93
|
+
"|" + widths.map { |width| "-" * (width + 2) }.join("|") + "|"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def build_data_rows(rows, headers, widths)
|
|
97
|
+
rows.map do |row|
|
|
98
|
+
build_single_data_row(row, headers, widths)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def build_single_data_row(row, headers, widths)
|
|
103
|
+
padded_row = pad_row_to_header_length(row, headers.length)
|
|
104
|
+
formatted_cells = format_row_cells(padded_row, widths)
|
|
105
|
+
"| " + formatted_cells.join(" | ") + " |"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def pad_row_to_header_length(row, header_length)
|
|
109
|
+
padding_needed = [0, header_length - row.length].max
|
|
110
|
+
row + Array.new(padding_needed, "")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def format_row_cells(row, widths)
|
|
114
|
+
row.map.with_index do |cell, i|
|
|
115
|
+
cell_str = truncate_cell(cell.to_s, widths[i])
|
|
116
|
+
cell_str.ljust(widths[i])
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def calculate_column_widths(headers, rows)
|
|
121
|
+
table_style = @options.fetch(:table_style, :standard)
|
|
122
|
+
min_width = @options.fetch(:min_column_width, 3)
|
|
123
|
+
max_width = @options.fetch(:max_column_width, 30)
|
|
124
|
+
|
|
125
|
+
# Calculate the maximum width needed for each column
|
|
126
|
+
widths = headers.map(&:length)
|
|
127
|
+
|
|
128
|
+
rows.each do |row|
|
|
129
|
+
row.each_with_index do |cell, i|
|
|
130
|
+
next if i >= widths.length
|
|
131
|
+
|
|
132
|
+
cell_length = cell.to_s.length
|
|
133
|
+
|
|
134
|
+
# Apply max width limit for readability (like the old component did)
|
|
135
|
+
cell_length = [max_width, cell_length].min if %i[compact standard].include?(table_style)
|
|
136
|
+
|
|
137
|
+
widths[i] = [widths[i], cell_length].max
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Apply minimum width and final constraints
|
|
142
|
+
case table_style
|
|
143
|
+
when :compact
|
|
144
|
+
widths.map { |w| [[w, min_width].max, max_width].min }
|
|
145
|
+
when :wide
|
|
146
|
+
widths.map { |w| [w, min_width].max } # No max limit for wide style
|
|
147
|
+
else # :standard
|
|
148
|
+
widths.map { |w| [[w, min_width].max, max_width].min } # Apply max limit for standard too
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def truncate_cell(text, max_width)
|
|
153
|
+
return text if text.length <= max_width
|
|
154
|
+
return text if max_width < 4
|
|
155
|
+
|
|
156
|
+
# Smart truncation for hash-like structures
|
|
157
|
+
if text.start_with?("{") && text.include?("=>")
|
|
158
|
+
# For hash representations, create a valid truncated version
|
|
159
|
+
truncate_hash_representation(text, max_width)
|
|
160
|
+
else
|
|
161
|
+
# Standard truncation for other values
|
|
162
|
+
"#{text[0..max_width - 4]}..."
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def truncate_hash_representation(text, max_width)
|
|
167
|
+
return text if max_width < 6 # Need space for "{...}"
|
|
168
|
+
|
|
169
|
+
# Try to fit at least the first key-value pair
|
|
170
|
+
if max_width >= 15
|
|
171
|
+
# Look for the first complete key-value pair (handle :symbol=>value format)
|
|
172
|
+
match = text.match(/\{(:[^,}]+=>[^,}]+)/)
|
|
173
|
+
if match
|
|
174
|
+
first_pair = match[1]
|
|
175
|
+
needed_length = first_pair.length + 6 # For "{", ", ...}"
|
|
176
|
+
return "{#{first_pair}, ...}" if needed_length <= max_width
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Fallback to simple hash indicator
|
|
181
|
+
"{...}"
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "table_builder"
|
|
4
|
+
|
|
5
|
+
module ClassMetrix
|
|
6
|
+
module Formatters
|
|
7
|
+
module Shared
|
|
8
|
+
class CsvTableBuilder < TableBuilder
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def process_value(value)
|
|
12
|
+
@value_processor.process_for_csv(value, @options)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def get_null_value
|
|
16
|
+
@options.fetch(:null_value, "")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "table_builder"
|
|
4
|
+
|
|
5
|
+
module ClassMetrix
|
|
6
|
+
module Formatters
|
|
7
|
+
module Shared
|
|
8
|
+
class MarkdownTableBuilder < TableBuilder
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def process_value(value)
|
|
12
|
+
@value_processor.process_for_markdown(value)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def get_null_value
|
|
16
|
+
@value_processor.missing_hash_key
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Create proper flat table structure for hash expansion
|
|
20
|
+
def expand_row(row, _headers)
|
|
21
|
+
behavior_name = row[behavior_column_index]
|
|
22
|
+
values = row[value_start_index..]
|
|
23
|
+
|
|
24
|
+
all_hash_keys = collect_unique_hash_keys(values)
|
|
25
|
+
return [process_row(row)] if all_hash_keys.empty?
|
|
26
|
+
|
|
27
|
+
build_expanded_row_structure(row, behavior_name, values, all_hash_keys)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def collect_unique_hash_keys(values)
|
|
31
|
+
all_hash_keys = Set.new
|
|
32
|
+
values.each do |value|
|
|
33
|
+
all_hash_keys.merge(value.keys.map(&:to_s)) if value.is_a?(Hash)
|
|
34
|
+
end
|
|
35
|
+
all_hash_keys
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def build_expanded_row_structure(row, behavior_name, values, all_hash_keys)
|
|
39
|
+
expanded_rows = []
|
|
40
|
+
expanded_rows << build_main_row(row, behavior_name, values)
|
|
41
|
+
expanded_rows.concat(build_hash_key_rows(row, behavior_name, values, all_hash_keys))
|
|
42
|
+
expanded_rows
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def build_main_row(row, behavior_name, values)
|
|
46
|
+
processed_values = values.map { |value| process_value(value) }
|
|
47
|
+
|
|
48
|
+
if has_type_column?
|
|
49
|
+
[row[0], behavior_name] + processed_values
|
|
50
|
+
else
|
|
51
|
+
[behavior_name] + processed_values
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def build_hash_key_rows(row, behavior_name, values, all_hash_keys)
|
|
56
|
+
all_hash_keys.to_a.sort.map do |key|
|
|
57
|
+
build_single_key_row(row, behavior_name, values, key)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_single_key_row(row, behavior_name, values, key)
|
|
62
|
+
path_name = "#{behavior_name}.#{key}"
|
|
63
|
+
key_values = extract_key_values_for_row(values, key)
|
|
64
|
+
|
|
65
|
+
if has_type_column?
|
|
66
|
+
[row[0], path_name] + key_values # Keep original type
|
|
67
|
+
else
|
|
68
|
+
[path_name] + key_values
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def extract_key_values_for_row(values, key)
|
|
73
|
+
values.map do |value|
|
|
74
|
+
extract_single_key_value(value, key)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def extract_single_key_value(value, key)
|
|
79
|
+
if value.is_a?(Hash)
|
|
80
|
+
extract_hash_key_value(value, key)
|
|
81
|
+
else
|
|
82
|
+
get_null_value
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def extract_hash_key_value(hash, key)
|
|
87
|
+
if @value_processor.has_hash_key?(hash, key)
|
|
88
|
+
hash_value = @value_processor.safe_hash_lookup(hash, key)
|
|
89
|
+
process_value(hash_value)
|
|
90
|
+
else
|
|
91
|
+
get_null_value
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|