na 1.2.79 → 1.2.81

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.
data/lib/na/action.rb CHANGED
@@ -12,12 +12,20 @@ module NA
12
12
  @file = file
13
13
  @project = project
14
14
  @parent = parent
15
- @action = action.gsub(/\{/, '\\{')
15
+ @action = action.gsub('{', '\\{')
16
16
  @tags = scan_tags
17
17
  @line = idx
18
18
  @note = note
19
19
  end
20
20
 
21
+ # Update the action string and note with priority, tags, and completion status
22
+ #
23
+ # @param priority [Integer] Priority value to set
24
+ # @param finish [Boolean] Mark as finished
25
+ # @param add_tag [Array<String>] Tags to add
26
+ # @param remove_tag [Array<String>] Tags to remove
27
+ # @param note [Array<String>] Notes to set
28
+ # @return [void]
21
29
  def process(priority: 0, finish: false, add_tag: [], remove_tag: [], note: [])
22
30
  string = @action.dup
23
31
 
@@ -44,6 +52,9 @@ module NA
44
52
  @note = note unless note.empty?
45
53
  end
46
54
 
55
+ # String representation of the action
56
+ #
57
+ # @return [String]
47
58
  def to_s
48
59
  note = if @note.count.positive?
49
60
  "\n#{@note.join("\n")}"
@@ -53,102 +64,153 @@ module NA
53
64
  "(#{@file}:#{@line}) #{@project}:#{@parent.join('>')} | #{@action}#{note}"
54
65
  end
55
66
 
67
+ # Pretty string representation of the action with color formatting
68
+ #
69
+ # @return [String]
56
70
  def to_s_pretty
57
71
  note = if @note.count.positive?
58
72
  "\n#{@note.join("\n")}"
59
73
  else
60
74
  ''
61
75
  end
62
- "#{NA.theme[:filename]}#{File.basename(@file)}:#{@line}#{NA.theme[:bracket]}[#{NA.theme[:project]}#{@project}:#{@parent.join(">")}#{NA.theme[:bracket]}]{x} | #{NA.theme[:action]}#{@action}#{NA.theme[:note]}#{note}"
76
+ "{x}#{NA.theme[:filename]}#{File.basename(@file)}:#{@line}{x}#{NA.theme[:bracket]}[{x}#{NA.theme[:project]}#{@project}:#{@parent.join('>')}{x}#{NA.theme[:bracket]}]{x} | #{NA.theme[:action]}#{@action}{x}#{NA.theme[:note]}#{note}"
63
77
  end
64
78
 
79
+ # Inspect the action object
80
+ #
81
+ # @return [String]
65
82
  def inspect
66
83
  <<~EOINSPECT
67
- @file: #{@file}
68
- @project: #{@project}
69
- @parent: #{@parent.join('>')}
70
- @action: #{@action}
71
- @tags: #{@tags}
72
- @note: #{@note}
84
+ @file: #{@file}
85
+ @project: #{@project}
86
+ @parent: #{@parent.join('>')}
87
+ @action: #{@action}
88
+ @tags: #{@tags}
89
+ @note: #{@note}
73
90
  EOINSPECT
74
91
  end
75
92
 
76
- ##
77
- ## Pretty print an action
78
- ##
79
- ## @param extension [String] The file extension
80
- ## @param template [Hash] The template to use for
81
- ## colorization
82
- ## @param regexes [Array] The regexes to
83
- ## highlight (searches)
84
- ## @param notes [Boolean] Include notes
85
- ##
93
+ #
94
+ # Pretty print an action with color and template formatting
95
+ #
96
+ # @param extension [String] File extension
97
+ # @param template [Hash] Color template
98
+ # @param regexes [Array] Regexes to highlight
99
+ # @param notes [Boolean] Include notes
100
+ # @param detect_width [Boolean] Detect terminal width
101
+ # @return [String]
86
102
  def pretty(extension: 'taskpaper', template: {}, regexes: [], notes: false, detect_width: true)
87
- theme = NA::Theme.load_theme
88
- template = theme.merge(template)
89
-
90
- # Create the hierarchical parent string
91
- parents = @parent.map do |par|
92
- NA::Color.template("{x}#{template[:parent]}#{par}")
93
- end.join(NA::Color.template(template[:parent_divider]))
94
- parents = "#{NA.theme[:bracket]}[#{NA.theme[:error]}#{parents}#{NA.theme[:bracket]}]{x} "
95
-
96
- # Create the project string
97
- project = NA::Color.template("#{template[:project]}#{@project}{x} ")
98
-
99
- # Create the source filename string, substituting ~ for HOME and removing extension
100
- file = @file.sub(%r{^\./}, '').sub(/#{ENV['HOME']}/, '~')
101
- file = file.sub(/\.#{extension}$/, '') unless NA.include_ext
102
- # colorize the basename
103
- file = file.highlight_filename
104
- file_tpl = "#{template[:file]}#{file} {x}"
105
- filename = NA::Color.template(file_tpl)
106
-
107
- # colorize the action and highlight tags
108
- @action.gsub!(/\{(.*?)\}/, '\\{\1\\}')
109
- action = NA::Color.template("#{template[:action]}#{@action.sub(/ @#{NA.na_tag}\b/, '')}{x}")
110
- action = action.highlight_tags(color: template[:tags],
111
- parens: template[:value_parens],
112
- value: template[:values],
113
- last_color: template[:action])
114
-
115
- if detect_width
116
- width = TTY::Screen.columns
117
- prefix = NA::Color.uncolor(pretty(template: { templates: { output: template[:templates][:output].sub(/%action/, '').sub(/%note/, '') } }, detect_width: false))
118
- indent = prefix.length
119
-
120
- # Add notes if needed
121
- note = if notes && @note.count.positive?
122
- NA::Color.template(@note.wrap(width, indent, template[:note]))
123
- elsif !notes && @note.count.positive?
124
- action += "#{template[:note]}*"
125
- else
126
- ''
127
- end
128
-
129
- action = action.wrap(width, indent)
130
- else
131
- note = if notes && @note.count.positive?
132
- NA::Color.template("\n#{@note.map { |l| " #{template[:note]}#{l.wrap(width, indent)}{x}" }.join("\n")}")
133
- elsif !notes && @note.count.positive?
134
- action += "#{template[:note]}*"
135
- else
136
- ''
137
- end
138
- end
103
+ NA::Benchmark.measure('Action.pretty') do
104
+ # Use cached theme instead of loading every time
105
+ theme = NA.theme
106
+ template = theme.merge(template)
107
+
108
+ # Pre-compute common template parts to avoid repeated processing
109
+ output_template = template[:templates][:output]
110
+ needs_filename = output_template.include?('%filename')
111
+ needs_parents = output_template.include?('%parents') || output_template.include?('%parent')
112
+ needs_project = output_template.include?('%project')
113
+
114
+ # Create the hierarchical parent string (optimized)
115
+ parents = if needs_parents && @parent.any?
116
+ parent_parts = @parent.map { |par| "#{template[:parent]}#{par}" }.join(template[:parent_divider])
117
+ NA::Color.template("{x}#{template[:bracket]}[#{template[:error]}#{parent_parts}{x}#{template[:bracket]}]{x} ")
118
+ else
119
+ ''
120
+ end
121
+
122
+ # Create the project string (optimized)
123
+ project = if needs_project && !@project.empty?
124
+ NA::Color.template("{x}#{template[:project]}#{@project}{x} ")
125
+ else
126
+ ''
127
+ end
128
+
129
+ # Create the source filename string (optimized)
130
+ filename = if needs_filename
131
+ path = @file.sub(%r{^\./}, '').sub(/#{Dir.home}/, '~')
132
+ if File.dirname(path) == '.'
133
+ fname = NA.include_ext ? File.basename(path) : File.basename(path, ".#{extension}")
134
+ fname = "./#{fname}" if NA.show_cwd_indicator
135
+ NA::Color.template("#{template[:filename]}#{fname} {x}")
136
+ else
137
+ colored = (NA.include_ext ? path : path.sub(/\.#{extension}$/, '')).highlight_filename
138
+ NA::Color.template("#{template[:filename]}#{colored} {x}")
139
+ end
140
+ else
141
+ ''
142
+ end
143
+
144
+ # colorize the action and highlight tags (optimized)
145
+ action_text = @action.dup
146
+ action_text.gsub!(/\{(.*?)\}/, '\\{\1\\}')
147
+ action_text = action_text.sub(/ @#{NA.na_tag}\b/, '')
148
+ action = NA::Color.template("#{template[:action]}#{action_text}{x}")
149
+ action = action.highlight_tags(color: template[:tags],
150
+ parens: template[:value_parens],
151
+ value: template[:values],
152
+ last_color: template[:action])
153
+
154
+ # Handle notes and wrapping (optimized)
155
+ note = ''
156
+ if @note.any?
157
+ if notes
158
+ if detect_width
159
+ # Cache width calculation
160
+ width = @cached_width ||= TTY::Screen.columns
161
+ # Calculate indent more efficiently - avoid repeated template processing
162
+ base_template = output_template.gsub('%action', '').gsub('%note', '')
163
+ base_output = base_template.gsub('%filename', filename).gsub('%project', project).gsub(/%parents?/,
164
+ parents)
165
+ indent = NA::Color.uncolor(NA::Color.template(base_output)).length
166
+ note = NA::Color.template(@note.wrap(width, indent, template[:note]))
167
+ else
168
+ note = NA::Color.template("\n#{@note.map { |l| " {x}#{template[:note]}• #{l}{x}" }.join("\n")}")
169
+ end
170
+ else
171
+ action += "{x}#{template[:note]}*"
172
+ end
173
+ end
174
+
175
+ # Wrap action if needed (optimized)
176
+ if detect_width && !action.empty?
177
+ width = @cached_width ||= TTY::Screen.columns
178
+ base_template = output_template.gsub('%action', '').gsub('%note', '')
179
+ base_output = base_template.gsub('%filename', filename).gsub('%project', project).gsub(/%parents?/, parents)
180
+ indent = NA::Color.uncolor(NA::Color.template(base_output)).length
181
+ action = action.wrap(width, indent)
182
+ end
183
+
184
+ # Replace variables in template string and output colorized (optimized)
185
+ final_output = output_template.dup
186
+ final_output.gsub!('%filename', filename)
187
+ final_output.gsub!('%project', project)
188
+ final_output.gsub!(/%parents?/, parents)
189
+ final_output.gsub!('%action', action.highlight_search(regexes))
190
+ final_output.gsub!('%note', note)
191
+ final_output.gsub!('\\{', '{')
139
192
 
140
- # Replace variables in template string and output colorized
141
- NA::Color.template(template[:templates][:output].gsub(/%filename/, filename)
142
- .gsub(/%project/, project)
143
- .gsub(/%parents?/, parents)
144
- .gsub(/%action/, action.highlight_search(regexes))
145
- .gsub(/%note/, note)).gsub(/\\\{/, '{')
193
+ NA::Color.template(final_output)
194
+ end
146
195
  end
147
196
 
197
+ # Check if action tags match any, all, and none criteria
198
+ #
199
+ # @param any [Array] Tags to match any
200
+ # @param all [Array] Tags to match all
201
+ # @param none [Array] Tags to match none
202
+ # @return [Boolean]
148
203
  def tags_match?(any: [], all: [], none: [])
149
204
  tag_matches_any(any) && tag_matches_all(all) && tag_matches_none(none)
150
205
  end
151
206
 
207
+ # Check if action or note matches any, all, and none search criteria
208
+ #
209
+ # @param any [Array] Regexes to match any
210
+ # @param all [Array] Regexes to match all
211
+ # @param none [Array] Regexes to match none
212
+ # @param include_note [Boolean] Include note in search
213
+ # @return [Boolean]
152
214
  def search_match?(any: [], all: [], none: [], include_note: true)
153
215
  search_matches_any(any, include_note: include_note) &&
154
216
  search_matches_all(all, include_note: include_note) &&
@@ -157,32 +219,54 @@ module NA
157
219
 
158
220
  private
159
221
 
222
+ # Check if action and note do not match any regexes
223
+ #
224
+ # @param regexes [Array] Regexes to check
225
+ # @param include_note [Boolean] Include note in search
226
+ # @return [Boolean]
160
227
  def search_matches_none(regexes, include_note: true)
161
228
  regexes.each do |rx|
162
- note_matches = include_note && @note.join(' ').match(Regexp.new(rx, Regexp::IGNORECASE))
163
- return false if @action.match(Regexp.new(rx, Regexp::IGNORECASE)) || note_matches
229
+ regex = rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE)
230
+ note_matches = include_note && @note.join(' ').match(regex)
231
+ return false if @action.match(regex) || note_matches
164
232
  end
165
233
  true
166
234
  end
167
235
 
236
+ # Check if action or note matches any regexes
237
+ #
238
+ # @param regexes [Array] Regexes to check
239
+ # @param include_note [Boolean] Include note in search
240
+ # @return [Boolean]
168
241
  def search_matches_any(regexes, include_note: true)
169
242
  return true if regexes.empty?
170
243
 
171
244
  regexes.each do |rx|
172
- note_matches = include_note && @note.join(' ').match(Regexp.new(rx, Regexp::IGNORECASE))
173
- return true if @action.match(Regexp.new(rx, Regexp::IGNORECASE)) || note_matches
245
+ regex = rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE)
246
+ note_matches = include_note && @note.join(' ').match(regex)
247
+ return true if @action.match(regex) || note_matches
174
248
  end
175
249
  false
176
250
  end
177
251
 
252
+ # Check if action or note matches all regexes
253
+ #
254
+ # @param regexes [Array] Regexes to check
255
+ # @param include_note [Boolean] Include note in search
256
+ # @return [Boolean]
178
257
  def search_matches_all(regexes, include_note: true)
179
258
  regexes.each do |rx|
180
- note_matches = include_note && @note.join(' ').match(Regexp.new(rx, Regexp::IGNORECASE))
181
- return false unless @action.match(Regexp.new(rx, Regexp::IGNORECASE)) || note_matches
259
+ regex = rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE)
260
+ note_matches = include_note && @note.join(' ').match(regex)
261
+ return false unless @action.match(regex) || note_matches
182
262
  end
183
263
  true
184
264
  end
185
265
 
266
+ # Check if none of the tags match
267
+ #
268
+ # @param tags [Array] Tags to check
269
+ # @return [Boolean]
186
270
  def tag_matches_none(tags)
187
271
  tags.each do |tag|
188
272
  return false if compare_tag(tag)
@@ -190,6 +274,10 @@ module NA
190
274
  true
191
275
  end
192
276
 
277
+ # Check if any of the tags match
278
+ #
279
+ # @param tags [Array] Tags to check
280
+ # @return [Boolean]
193
281
  def tag_matches_any(tags)
194
282
  return true if tags.empty?
195
283
 
@@ -199,6 +287,10 @@ module NA
199
287
  false
200
288
  end
201
289
 
290
+ # Check if all of the tags match
291
+ #
292
+ # @param tags [Array] Tags to check
293
+ # @return [Boolean]
202
294
  def tag_matches_all(tags)
203
295
  tags.each do |tag|
204
296
  return false unless compare_tag(tag)
@@ -206,8 +298,13 @@ module NA
206
298
  true
207
299
  end
208
300
 
301
+ # Compare a tag against the action's tags with optional value comparison
302
+ #
303
+ # @param tag [Hash] Tag criteria
304
+ # @return [Boolean]
209
305
  def compare_tag(tag)
210
- keys = @tags.keys.delete_if { |k| k !~ Regexp.new(tag[:tag], Regexp::IGNORECASE) }
306
+ tag_regex = tag[:tag].is_a?(Regexp) ? tag[:tag] : Regexp.new(tag[:tag], Regexp::IGNORECASE)
307
+ keys = @tags.keys.delete_if { |k| k !~ tag_regex }
211
308
  return false if keys.empty?
212
309
 
213
310
  key = keys[0]
@@ -220,6 +317,7 @@ module NA
220
317
 
221
318
  begin
222
319
  tag_date = Time.parse(tag_val)
320
+ require 'chronic' unless defined?(Chronic)
223
321
  date = Chronic.parse(val)
224
322
 
225
323
  raise ArgumentError if date.nil?
data/lib/na/actions.rb CHANGED
@@ -5,89 +5,114 @@ module NA
5
5
  class Actions < Array
6
6
  def initialize(actions = [])
7
7
  super
8
- concat(actions)
9
8
  end
10
9
 
11
- ##
12
- ## Pretty print a list of actions
13
- ##
14
- ## @param depth [Integer] The depth of the action
15
- ## @param config [Hash] The configuration options
16
- ##
17
- ## @option config [Array] :files The files to include in the output
18
- ## @option config [Array] :regexes The regexes to match against
19
- ## @option config [Boolean] :notes Whether to include notes in the output
20
- ## @option config [Boolean] :nest Whether to nest the output
21
- ## @option config [Boolean] :nest_projects Whether to nest projects in the output
22
- ## @option config [Boolean] :no_files Whether to include files in the output
23
- ##
24
- ## @return [String] The output string
25
- ##
10
+ # Pretty print a list of actions
11
+ #
12
+ # @param depth [Integer] The depth of the action
13
+ # @param config [Hash] The configuration options
14
+ # @option config [Array] :files The files to include in the output
15
+ # @option config [Array] :regexes The regexes to match against
16
+ # @option config [Boolean] :notes Whether to include notes in the output
17
+ # @option config [Boolean] :nest Whether to nest the output
18
+ # @option config [Boolean] :nest_projects Whether to nest projects in the output
19
+ # @option config [Boolean] :no_files Whether to include files in the output
20
+ # @return [String] The output string
26
21
  def output(depth, config = {})
27
- defaults = {
28
- files: nil,
29
- regexes: [],
30
- notes: false,
31
- nest: false,
32
- nest_projects: false,
33
- no_files: false,
34
- }
35
- config = defaults.merge(config)
22
+ NA::Benchmark.measure('Actions.output') do
23
+ defaults = {
24
+ files: nil,
25
+ regexes: [],
26
+ notes: false,
27
+ nest: false,
28
+ nest_projects: false,
29
+ no_files: false
30
+ }
31
+ config = defaults.merge(config)
36
32
 
37
- return if config[:files].nil?
33
+ return if config[:files].nil?
38
34
 
39
- if config[:nest]
40
- template = NA.theme[:templates][:default]
41
- template = NA.theme[:templates][:no_file] if config[:no_files]
35
+ if config[:nest]
36
+ template = NA.theme[:templates][:default]
37
+ template = NA.theme[:templates][:no_file] if config[:no_files]
42
38
 
43
- parent_files = {}
44
- out = []
39
+ parent_files = {}
40
+ out = []
45
41
 
46
- if config[:nest_projects]
47
- each do |action|
48
- parent_files[action.file] ||= []
49
- parent_files[action.file].push(action)
50
- end
42
+ if config[:nest_projects]
43
+ each do |action|
44
+ parent_files[action.file] ||= []
45
+ parent_files[action.file].push(action)
46
+ end
51
47
 
52
- parent_files.each do |file, acts|
53
- projects = NA.project_hierarchy(acts)
54
- out.push("#{file.sub(%r{^./}, "").shorten_path}:")
55
- out.concat(NA.output_children(projects, 0))
48
+ parent_files.each do |file, acts|
49
+ projects = NA.project_hierarchy(acts)
50
+ out.push("#{file.sub(%r{^./}, '').shorten_path}:")
51
+ out.concat(NA.output_children(projects, 0))
52
+ end
53
+ else
54
+ template = NA.theme[:templates][:default]
55
+ template = NA.theme[:templates][:no_file] if config[:no_files]
56
+
57
+ each do |action|
58
+ parent_files[action.file] ||= []
59
+ parent_files[action.file].push(action)
60
+ end
61
+
62
+ parent_files.each do |file, acts|
63
+ out.push("#{file.sub(%r{^\./}, '')}:")
64
+ acts.each do |a|
65
+ out.push("\t- [#{a.parent.join('/')}] #{a.action}")
66
+ out.push("\t\t#{a.note.join("\n\t\t")}") unless a.note.empty?
67
+ end
68
+ end
56
69
  end
70
+ NA::Pager.page out.join("\n")
57
71
  else
58
- template = NA.theme[:templates][:default]
59
- template = NA.theme[:templates][:no_file] if config[:no_files]
72
+ # Optimize template selection
73
+ template = if config[:no_files]
74
+ NA.theme[:templates][:no_file]
75
+ elsif config[:files]&.count&.positive?
76
+ config[:files].count == 1 ? NA.theme[:templates][:single_file] : NA.theme[:templates][:multi_file]
77
+ elsif depth > 1
78
+ NA.theme[:templates][:multi_file]
79
+ else
80
+ NA.theme[:templates][:default]
81
+ end
82
+ template += '%note' if config[:notes]
60
83
 
61
- each do |action|
62
- parent_files[action.file] ||= []
63
- parent_files[action.file].push(action)
84
+ # Show './' for current directory only when listing also includes subdir files
85
+ if template == NA.theme[:templates][:multi_file]
86
+ has_subdir = config[:files]&.any? { |f| File.dirname(f) != '.' } || depth > 1
87
+ NA.show_cwd_indicator = !has_subdir.nil?
88
+ else
89
+ NA.show_cwd_indicator = false
64
90
  end
65
91
 
66
- parent_files.each do |file, acts|
67
- out.push("#{file.sub(%r{^\./}, "")}:")
68
- acts.each do |a|
69
- out.push("\t- [#{a.parent.join("/")}] #{a.action}")
70
- out.push("\t\t#{a.note.join("\n\t\t")}") unless a.note.empty?
92
+ # Skip debug output if not verbose
93
+ config[:files]&.each { |f| NA.notify(f, debug: true) } if config[:files] && NA.verbose
94
+
95
+ # Optimize output generation - compile all output first, then apply regexes
96
+ output = String.new
97
+ NA::Benchmark.measure('Generate action strings') do
98
+ each_with_index do |action, idx|
99
+ # Generate raw output without regex processing
100
+ output << action.pretty(template: { templates: { output: template } }, regexes: [], notes: config[:notes])
101
+ output << "\n" unless idx == size - 1
71
102
  end
72
103
  end
73
- end
74
- NA::Pager.page out.join("\n")
75
- else
76
- template = if config[:no_files]
77
- NA.theme[:templates][:no_file]
78
- elsif config[:files].count.positive?
79
- config[:files].count == 1 ? NA.theme[:templates][:single_file] : NA.theme[:templates][:multi_file]
80
- elsif NA.find_files(depth: depth).count > 1
81
- depth > 1 ? NA.theme[:templates][:multi_file] : NA.theme[:templates][:single_file]
82
- else
83
- NA.theme[:templates][:default]
84
- end
85
- template += "%note" if config[:notes]
86
104
 
87
- config[:files].map { |f| NA.notify(f, debug: true) } if config[:files]
105
+ # Apply regex highlighting to the entire output at once
106
+ if config[:regexes].any?
107
+ NA::Benchmark.measure('Apply regex highlighting') do
108
+ output = output.highlight_search(config[:regexes])
109
+ end
110
+ end
88
111
 
89
- output = map { |action| action.pretty(template: { templates: { output: template } }, regexes: config[:regexes], notes: config[:notes]) }
90
- NA::Pager.page(output.join("\n"))
112
+ NA::Benchmark.measure('Pager.page call') do
113
+ NA::Pager.page(output)
114
+ end
115
+ end
91
116
  end
92
117
  end
93
118
  end
data/lib/na/array.rb CHANGED
@@ -1,21 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ::Array
4
- ##
5
- ## Like Array#compact -- removes nil items, but also
6
- ## removes empty strings, zero or negative numbers and FalseClass items
7
- ##
8
- ## @return [Array] Array without "bad" elements
9
- ##
4
+ # Like Array#compact -- removes nil items, but also
5
+ # removes empty strings, zero or negative numbers and FalseClass items
6
+ #
7
+ # @return [Array] Array without "bad" elements
10
8
  def remove_bad
11
9
  compact.map { |x| x.is_a?(String) ? x.strip : x }.select(&:good?)
12
10
  end
13
11
 
12
+ # Wrap each string in the array to the given width and indent, with color
13
+ #
14
+ # @param width [Integer] Maximum line width
15
+ # @param indent [Integer] Indentation spaces
16
+ # @param color [String] Color code to apply
17
+ # @return [Array, String] Wrapped and colorized lines
14
18
  def wrap(width, indent, color)
15
19
  return map { |l| "#{color} #{l.wrap(width, 2)}" } if width < 60
16
20
 
17
21
  map! do |l|
18
- "#{color}#{' ' * indent }• #{l.wrap(width, indent)}{x}"
22
+ "#{color}#{' ' * indent}• #{l.wrap(width, indent)}{x}"
19
23
  end
20
24
  "\n#{join("\n")}"
21
25
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NA
4
+ module Benchmark
5
+ class << self
6
+ attr_accessor :enabled, :timings
7
+
8
+ # Initialize benchmarking state
9
+ #
10
+ # @return [void]
11
+ def init
12
+ @enabled = %w[1 true].include?(ENV.fetch('NA_BENCHMARK', nil))
13
+ @timings = []
14
+ @start_time = Time.now
15
+ end
16
+
17
+ # Measure the execution time of a block
18
+ #
19
+ # @param label [String] Label for the measurement
20
+ # @return [Object] Result of the block
21
+ def measure(label)
22
+ return yield unless @enabled
23
+
24
+ start = Time.now
25
+ result = yield
26
+ duration = ((Time.now - start) * 1000).round(2)
27
+ @timings << { label: label, duration: duration, timestamp: (start - @start_time) * 1000 }
28
+ result
29
+ end
30
+
31
+ # Output a performance report to STDERR
32
+ #
33
+ # @return [void]
34
+ def report
35
+ return unless @enabled
36
+
37
+ total = @timings.sum { |t| t[:duration] }
38
+ warn "\n#{NA::Color.template('{y}=== NA Performance Report ===')}"
39
+ warn NA::Color.template("{dw}Total: {bw}#{total.round(2)}ms{x}")
40
+ warn NA::Color.template("{dw}GC Count: {bw}#{GC.count}{x}") if defined?(GC)
41
+ if defined?(GC)
42
+ warn NA::Color.template("{dw}Memory: {bw}#{(GC.stat[:heap_live_slots] * 40 / 1024.0).round(1)}KB{x}")
43
+ end
44
+ warn ''
45
+
46
+ @timings.each do |timing|
47
+ pct = total.positive? ? ((timing[:duration] / total) * 100).round(1) : 0
48
+ bar = '█' * [(pct / 2).round, 50].min
49
+ warn NA::Color.template(
50
+ "{dw}[{y}#{bar.ljust(25)}{dw}] {bw}#{timing[:duration].to_s.rjust(7)}ms {dw}(#{pct.to_s.rjust(5)}%) {x}#{timing[:label]}"
51
+ )
52
+ end
53
+ warn NA::Color.template("{y}#{'=' * 50}{x}\n")
54
+ end
55
+ end
56
+ end
57
+ end