na 1.2.79 → 1.2.80

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: 482bb5668dec4d69c1a9fc41340f723589724730fb930ff9f45d5bbaa5bd3dd3
4
- data.tar.gz: fa3cd141c8cf0a7ad41d52f399e9f400bd35708112d3ce9cab6bf2b7795a96b1
3
+ metadata.gz: 65f46379443596b7f52385686e09c6b558c2f6eaaab614a34cd55432921fc1f7
4
+ data.tar.gz: 5050ee855d909fb050ecde79cae733fcfa5d411b6f28d48e88661569f0ed9989
5
5
  SHA512:
6
- metadata.gz: a58d99161596166ce0b0ae34b45a35bcd86239e3606b05ec1431aed999f693475391a2e21ea3a7fdfb80bcf8d88ef2c3bcd3d20daaf34e474b2ab561d5051358
7
- data.tar.gz: 02f36b28b78d5fae3144450a34bb910c22a6097dc13512b12e35bbc34d290a2f085500865bba4f2514e55692d9d862ec26854662eb438557e2d8a50473f511e6
6
+ metadata.gz: 2aa83793e127693a7481e4f0294ff98067f7b32ad288445d6b23ce2744f2f975ecc4b854c48eb8faee91f2e5fc720e05732001003eb04a0ccede62b48bf3c685
7
+ data.tar.gz: 21560b1ae71aa1559070ad8768d0ddacaa3e60a6287bff35bc07a0c3ffb6208c23d83da39baee6ffc5e470d76d2c4e6793163ee08c2e2a7fd6ae6763af1900fc
data/CHANGELOG.md CHANGED
@@ -1,3 +1,39 @@
1
+ ### 1.2.80
2
+
3
+ 2025-10-23 05:26
4
+
5
+ #### CHANGED
6
+
7
+ - Default behavior: git integration now opt-in with --repo-top flag
8
+ - Pager execution: use spawn for better performance than fork+exec
9
+ - Color template processing: cache compiled templates and colors hash
10
+ - Action processing: batch regex highlighting instead of per-action processing
11
+
12
+ #### NEW
13
+
14
+ - Add comprehensive benchmarking system with NA_BENCHMARK=1
15
+ - Add template caching in Color.template to avoid repeated regex processing
16
+ - Add colors hash caching to eliminate repeated hash creation
17
+ - Add smart pagination that skips pager for small outputs (<2000 chars, <50 lines)
18
+ - Add --repo-top flag to make git integration opt-in instead of default
19
+
20
+ #### IMPROVED
21
+
22
+ - Optimize performance
23
+ - Theme loading now uses cached NA.theme instead of loading on every Action.pretty call
24
+ - Action.pretty method with conditional processing and optimized string operations
25
+ - Actions.output with batch regex processing - compile all output first, then apply regexes once
26
+ - Pager performance using spawn instead of fork+exec and removing IO.select delays
27
+ - Lazy loading of heavy gems (git, chronic, mdless) only when needed
28
+ - String operations in Action.pretty with pre-computed template parts
29
+
30
+ #### FIXED
31
+
32
+ - Performance variability caused by git system calls running by default
33
+ - Pager overhead causing 300-479ms delays on small outputs
34
+ - Repeated regex compilation in color template processing
35
+ - Theme loading bottleneck in Action.pretty method
36
+
1
37
  ### 1.2.79
2
38
 
3
39
  2025-09-29 06:51
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- na (1.2.79)
4
+ na (1.2.80)
5
5
  chronic (~> 0.10, >= 0.10.2)
6
6
  git (~> 3.0.0)
7
7
  gli (~> 2.21.0)
@@ -31,7 +31,7 @@ GEM
31
31
  public_suffix (>= 2.0.2, < 7.0)
32
32
  base64 (0.3.0)
33
33
  benchmark (0.4.1)
34
- bigdecimal (3.2.1)
34
+ bigdecimal (3.2.3)
35
35
  chronic (0.10.2)
36
36
  concurrent-ruby (1.3.5)
37
37
  connection_pool (2.5.3)
data/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  _If you're one of the rare people like me who find this useful, feel free to
10
10
  [buy me some coffee][donate]._
11
11
 
12
- The current version of `na` is 1.2.79.
12
+ The current version of `na` is 1.2.80.
13
13
 
14
14
  `na` ("next action") is a command line tool designed to make it easy to see what your next actions are for any project, right from the command line. It works with TaskPaper-formatted files (but any plain text format will do), looking for `@na` tags (or whatever you specify) in todo files in your current folder.
15
15
 
@@ -76,7 +76,7 @@ SYNOPSIS
76
76
  na [global options] command [command options] [arguments...]
77
77
 
78
78
  VERSION
79
- 1.2.79
79
+ 1.2.80
80
80
 
81
81
  GLOBAL OPTIONS
82
82
  -a, --add - Add a next action (deprecated, for backwards compatibility)
@@ -93,7 +93,7 @@ GLOBAL OPTIONS
93
93
  -p, --priority=PRIORITY - Set a priority 0-5 (deprecated, for backwards compatibility) (default: none)
94
94
  --[no-]pager - Enable pagination (default: enabled)
95
95
  -r, --[no-]recurse - Recurse 3 directories deep (deprecated, for backwards compatability)
96
- --[no-]repo - Use a taskpaper file named after the git repository (default: enabled)
96
+ --[no-]repo-top - Use a taskpaper file named after the git repository (enables git integration)
97
97
  -t, --na_tag=TAG - Tag to consider a next action (default: na)
98
98
  --template=arg - Provide a template for new/blank todo files, use initconfig to make permanent (default: none)
99
99
  --version - Display the program version
data/bin/na CHANGED
@@ -5,9 +5,13 @@ $LOAD_PATH.unshift File.join(__dir__, '..', 'lib')
5
5
  require 'gli'
6
6
  require 'na/help_monkey_patch'
7
7
  require 'na'
8
+ require 'na/benchmark'
8
9
  require 'fcntl'
9
10
  require 'tempfile'
10
11
 
12
+ NA::Benchmark.init
13
+ NA::Benchmark.measure('Gem loading') { nil } # Measures time up to this point
14
+
11
15
  # Search for XDG compliant config first. Default to ~/.na.rc for compatibility
12
16
  def self.find_config_file
13
17
  home = ENV['HOME']
@@ -75,9 +79,9 @@ class App
75
79
  arg_name 'PATH'
76
80
  flag %i[f file]
77
81
 
78
- desc 'Use a taskpaper file named after the git repository'
79
- arg_name 'REPO'
80
- switch %i[repo], negatable: true, default_value: true
82
+ desc 'Use a taskpaper file named after the git repository (enables git integration)'
83
+ arg_name 'REPO_TOP'
84
+ switch %i[repo-top], default_value: false
81
85
 
82
86
  desc 'Provide a template for new/blank todo files, use initconfig to make permanent'
83
87
  flag %[template]
@@ -125,8 +129,8 @@ class App
125
129
  end
126
130
 
127
131
  # start of git repo addition ==================================
128
- # defaut to git repo if in a git managed directory
129
- if global[:repo]
132
+ # Use git repo if --repo-top flag is specified
133
+ if global[:repo_top]
130
134
  begin
131
135
  require 'git'
132
136
 
@@ -209,4 +213,6 @@ ARGV.each do |arg|
209
213
  end
210
214
  NA.command = NA.command_line[0]
211
215
 
212
- exit App.run(ARGV)
216
+ exit_code = App.run(ARGV)
217
+ NA::Benchmark.report
218
+ exit exit_code
data/lib/na/action.rb CHANGED
@@ -84,65 +84,92 @@ module NA
84
84
  ## @param notes [Boolean] Include notes
85
85
  ##
86
86
  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
87
+ NA::Benchmark.measure('Action.pretty') do
88
+ # Use cached theme instead of loading every time
89
+ theme = NA.theme
90
+ template = theme.merge(template)
91
+
92
+ # Pre-compute common template parts to avoid repeated processing
93
+ output_template = template[:templates][:output]
94
+ needs_filename = output_template.include?('%filename')
95
+ needs_parents = output_template.include?('%parents') || output_template.include?('%parent')
96
+ needs_project = output_template.include?('%project')
97
+
98
+ # Create the hierarchical parent string (optimized)
99
+ parents = if needs_parents && @parent.any?
100
+ parent_parts = @parent.map { |par| "#{template[:parent]}#{par}" }.join(template[:parent_divider])
101
+ NA::Color.template("{x}#{template[:bracket]}[#{template[:error]}#{parent_parts}#{template[:bracket]}]{x} ")
102
+ else
103
+ ''
104
+ end
105
+
106
+ # Create the project string (optimized)
107
+ project = if needs_project && !@project.empty?
108
+ NA::Color.template("#{template[:project]}#{@project}{x} ")
109
+ else
110
+ ''
111
+ end
112
+
113
+ # Create the source filename string (optimized)
114
+ filename = if needs_filename
115
+ file = @file.sub(%r{^\./}, '').sub(/#{ENV['HOME']}/, '~')
116
+ file = file.sub(/\.#{extension}$/, '') unless NA.include_ext
117
+ file = file.highlight_filename
118
+ NA::Color.template("#{template[:filename]}#{file} {x}")
119
+ else
120
+ ''
121
+ end
122
+
123
+ # colorize the action and highlight tags (optimized)
124
+ action_text = @action.dup
125
+ action_text.gsub!(/\{(.*?)\}/, '\\{\1\\}')
126
+ action_text = action_text.sub(/ @#{NA.na_tag}\b/, '')
127
+ action = NA::Color.template("#{template[:action]}#{action_text}{x}")
128
+ action = action.highlight_tags(color: template[:tags],
129
+ parens: template[:value_parens],
130
+ value: template[:values],
131
+ last_color: template[:action])
132
+
133
+ # Handle notes and wrapping (optimized)
134
+ note = ''
135
+ if @note.any?
136
+ if notes
137
+ if detect_width
138
+ # Cache width calculation
139
+ width = @cached_width ||= TTY::Screen.columns
140
+ # Calculate indent more efficiently - avoid repeated template processing
141
+ base_template = output_template.gsub(/%action/, '').gsub(/%note/, '')
142
+ base_output = base_template.gsub(/%filename/, filename).gsub(/%project/, project).gsub(/%parents?/, parents)
143
+ indent = NA::Color.uncolor(NA::Color.template(base_output)).length
144
+ note = NA::Color.template(@note.wrap(width, indent, template[:note]))
145
+ else
146
+ note = NA::Color.template("\n#{@note.map { |l| " #{template[:note]}• #{l}{x}" }.join("\n")}")
147
+ end
148
+ else
149
+ action += "#{template[:note]}*"
150
+ end
151
+ end
152
+
153
+ # Wrap action if needed (optimized)
154
+ if detect_width && !action.empty?
155
+ width = @cached_width ||= TTY::Screen.columns
156
+ base_template = output_template.gsub(/%action/, '').gsub(/%note/, '')
157
+ base_output = base_template.gsub(/%filename/, filename).gsub(/%project/, project).gsub(/%parents?/, parents)
158
+ indent = NA::Color.uncolor(NA::Color.template(base_output)).length
159
+ action = action.wrap(width, indent)
160
+ end
161
+
162
+ # Replace variables in template string and output colorized (optimized)
163
+ final_output = output_template.dup
164
+ final_output.gsub!(/%filename/, filename)
165
+ final_output.gsub!(/%project/, project)
166
+ final_output.gsub!(/%parents?/, parents)
167
+ final_output.gsub!(/%action/, action.highlight_search(regexes))
168
+ final_output.gsub!(/%note/, note)
169
+ final_output.gsub!(/\\\{/, '{')
139
170
 
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(/\\\{/, '{')
171
+ NA::Color.template(final_output)
172
+ end
146
173
  end
147
174
 
148
175
  def tags_match?(any: [], all: [], none: [])
@@ -159,8 +186,9 @@ module NA
159
186
 
160
187
  def search_matches_none(regexes, include_note: true)
161
188
  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
189
+ regex = rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE)
190
+ note_matches = include_note && @note.join(' ').match(regex)
191
+ return false if @action.match(regex) || note_matches
164
192
  end
165
193
  true
166
194
  end
@@ -169,16 +197,18 @@ module NA
169
197
  return true if regexes.empty?
170
198
 
171
199
  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
200
+ regex = rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE)
201
+ note_matches = include_note && @note.join(' ').match(regex)
202
+ return true if @action.match(regex) || note_matches
174
203
  end
175
204
  false
176
205
  end
177
206
 
178
207
  def search_matches_all(regexes, include_note: true)
179
208
  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
209
+ regex = rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE)
210
+ note_matches = include_note && @note.join(' ').match(regex)
211
+ return false unless @action.match(regex) || note_matches
182
212
  end
183
213
  true
184
214
  end
@@ -207,7 +237,8 @@ module NA
207
237
  end
208
238
 
209
239
  def compare_tag(tag)
210
- keys = @tags.keys.delete_if { |k| k !~ Regexp.new(tag[:tag], Regexp::IGNORECASE) }
240
+ tag_regex = tag[:tag].is_a?(Regexp) ? tag[:tag] : Regexp.new(tag[:tag], Regexp::IGNORECASE)
241
+ keys = @tags.keys.delete_if { |k| k !~ tag_regex }
211
242
  return false if keys.empty?
212
243
 
213
244
  key = keys[0]
@@ -220,6 +251,7 @@ module NA
220
251
 
221
252
  begin
222
253
  tag_date = Time.parse(tag_val)
254
+ require 'chronic' unless defined?(Chronic)
223
255
  date = Chronic.parse(val)
224
256
 
225
257
  raise ArgumentError if date.nil?
data/lib/na/actions.rb CHANGED
@@ -24,15 +24,16 @@ module NA
24
24
  ## @return [String] The output string
25
25
  ##
26
26
  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)
27
+ NA::Benchmark.measure('Actions.output') do
28
+ defaults = {
29
+ files: nil,
30
+ regexes: [],
31
+ notes: false,
32
+ nest: false,
33
+ nest_projects: false,
34
+ no_files: false,
35
+ }
36
+ config = defaults.merge(config)
36
37
 
37
38
  return if config[:files].nil?
38
39
 
@@ -73,21 +74,43 @@ module NA
73
74
  end
74
75
  NA::Pager.page out.join("\n")
75
76
  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
77
+ # Optimize template selection
78
+ template = case
79
+ when config[:no_files]
80
+ NA.theme[:templates][:no_file]
81
+ when config[:files]&.count&.positive?
82
+ config[:files].count == 1 ? NA.theme[:templates][:single_file] : NA.theme[:templates][:multi_file]
83
+ when depth > 1
84
+ NA.theme[:templates][:multi_file]
85
+ else
86
+ NA.theme[:templates][:default]
87
+ end
85
88
  template += "%note" if config[:notes]
86
89
 
87
- config[:files].map { |f| NA.notify(f, debug: true) } if config[:files]
90
+ # Skip debug output if not verbose
91
+ config[:files]&.each { |f| NA.notify(f, debug: true) } if config[:files] && NA.verbose
92
+
93
+ # Optimize output generation - compile all output first, then apply regexes
94
+ output = String.new
95
+ NA::Benchmark.measure('Generate action strings') do
96
+ each_with_index do |action, idx|
97
+ # Generate raw output without regex processing
98
+ output << action.pretty(template: { templates: { output: template } }, regexes: [], notes: config[:notes])
99
+ output << "\n" unless idx == size - 1
100
+ end
101
+ end
88
102
 
89
- output = map { |action| action.pretty(template: { templates: { output: template } }, regexes: config[:regexes], notes: config[:notes]) }
90
- NA::Pager.page(output.join("\n"))
103
+ # Apply regex highlighting to the entire output at once
104
+ if config[:regexes].any?
105
+ NA::Benchmark.measure('Apply regex highlighting') do
106
+ output = output.highlight_search(config[:regexes])
107
+ end
108
+ end
109
+
110
+ NA::Benchmark.measure('Pager.page call') do
111
+ NA::Pager.page(output)
112
+ end
113
+ end
91
114
  end
92
115
  end
93
116
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NA
4
+ module Benchmark
5
+ class << self
6
+ attr_accessor :enabled, :timings
7
+
8
+ def init
9
+ @enabled = ENV['NA_BENCHMARK'] == '1' || ENV['NA_BENCHMARK'] == 'true'
10
+ @timings = []
11
+ @start_time = Time.now
12
+ end
13
+
14
+ def measure(label)
15
+ return yield unless @enabled
16
+
17
+ start = Time.now
18
+ result = yield
19
+ duration = ((Time.now - start) * 1000).round(2)
20
+ @timings << { label: label, duration: duration, timestamp: (start - @start_time) * 1000 }
21
+ result
22
+ end
23
+
24
+ def report
25
+ return unless @enabled
26
+
27
+ total = @timings.sum { |t| t[:duration] }
28
+ $stderr.puts "\n#{NA::Color.template('{y}=== NA Performance Report ===')}"
29
+ $stderr.puts NA::Color.template("{dw}Total: {bw}#{total.round(2)}ms{x}")
30
+ $stderr.puts NA::Color.template("{dw}GC Count: {bw}#{GC.count}{x}") if defined?(GC)
31
+ $stderr.puts NA::Color.template("{dw}Memory: {bw}#{(GC.stat[:heap_live_slots] * 40 / 1024.0).round(1)}KB{x}") if defined?(GC)
32
+ $stderr.puts ""
33
+
34
+ @timings.each do |timing|
35
+ pct = total > 0 ? ((timing[:duration] / total) * 100).round(1) : 0
36
+ bar = '█' * [(pct / 2).round, 50].min
37
+ $stderr.puts NA::Color.template(
38
+ "{dw}[{y}#{bar.ljust(25)}{dw}] {bw}#{timing[:duration].to_s.rjust(7)}ms {dw}(#{pct.to_s.rjust(5)}%) {x}#{timing[:label]}"
39
+ )
40
+ end
41
+ $stderr.puts NA::Color.template("{y}#{'=' * 50}{x}\n")
42
+ end
43
+ end
44
+ end
45
+ end
data/lib/na/colors.rb CHANGED
@@ -196,6 +196,24 @@ module NA
196
196
 
197
197
  attr_writer :coloring
198
198
 
199
+ # Cache for compiled templates to avoid repeated regex processing
200
+ def template_cache
201
+ @template_cache ||= {}
202
+ end
203
+
204
+ def clear_template_cache
205
+ @template_cache = {}
206
+ end
207
+
208
+ # Pre-computed colors hash (expensive to create, so we cache it)
209
+ def colors_hash
210
+ @colors_hash ||= { w: white, k: black, g: green, l: blue,
211
+ y: yellow, c: cyan, m: magenta, r: red,
212
+ W: bgwhite, K: bgblack, G: bggreen, L: bgblue,
213
+ Y: bgyellow, C: bgcyan, M: bgmagenta, R: bgred,
214
+ d: dark, b: bold, u: underline, i: italic, x: reset }
215
+ end
216
+
199
217
  ##
200
218
  ## Enables colored output
201
219
  ##
@@ -236,23 +254,28 @@ module NA
236
254
  input = input.join(' ') if input.is_a? Array
237
255
  return input.gsub(/(?<!\\)\{#?(\w+)\}/i, '') unless NA::Color.coloring?
238
256
 
239
- input = input.gsub(/(?<!\\)\{((?:[fb]g?)?#[a-f0-9]{3,6})\}/i) do
240
- hex = Regexp.last_match(1)
241
- rgb(hex)
242
- end
257
+ # Check cache first
258
+ cache_key = input.hash
259
+ return template_cache[cache_key] if template_cache.key?(cache_key)
260
+
261
+ # Process hex colors first
262
+ processed_input = input.gsub(/(?<!\\)\{((?:[fb]g?)?#[a-f0-9]{3,6})\}/i) do
263
+ hex = Regexp.last_match(1)
264
+ rgb(hex)
265
+ end
243
266
 
244
- fmt = input.gsub(/%/, '%%')
267
+ # Convert to format string
268
+ fmt = processed_input.gsub(/%/, '%%')
245
269
  fmt = fmt.gsub(/(?<!\\)\{(\w+)\}/i) do
246
270
  Regexp.last_match(1).split('').map { |c| "%<#{c}>s" }.join('')
247
271
  end
248
272
 
249
- colors = { w: white, k: black, g: green, l: blue,
250
- y: yellow, c: cyan, m: magenta, r: red,
251
- W: bgwhite, K: bgblack, G: bggreen, L: bgblue,
252
- Y: bgyellow, C: bgcyan, M: bgmagenta, R: bgred,
253
- d: dark, b: bold, u: underline, i: italic, x: reset }
273
+ # Use pre-computed colors hash
274
+ result = format(fmt, colors_hash)
254
275
 
255
- format(fmt, colors)
276
+ # Cache the result
277
+ template_cache[cache_key] = result
278
+ result
256
279
  end
257
280
  end
258
281
 
@@ -554,11 +554,19 @@ module NA
554
554
  ## @param depth [Number] The depth at which to search
555
555
  ##
556
556
  def find_files(depth: 1)
557
- return [NA.global_file] if NA.global_file
558
-
559
- files = `find . -maxdepth #{depth} -name "*.#{NA.extension}"`.strip.split("\n")
560
- files.each { |f| save_working_dir(File.expand_path(f)) }
561
- files
557
+ NA::Benchmark.measure("find_files (depth=#{depth})") do
558
+ return [NA.global_file] if NA.global_file
559
+
560
+ pattern = if depth == 1
561
+ "*.#{NA.extension}"
562
+ else
563
+ "{#{'*,' * (depth - 1)}*}.#{NA.extension}"
564
+ end
565
+
566
+ files = Dir.glob(pattern, File::FNM_DOTMATCH).reject { |f| f.start_with?('.') }
567
+ files.each { |f| save_working_dir(File.expand_path(f)) }
568
+ files
569
+ end
562
570
  end
563
571
 
564
572
  def find_files_matching(options = {})
@@ -671,12 +679,14 @@ module NA
671
679
  ## @param todo_file The todo file path
672
680
  ##
673
681
  def save_working_dir(todo_file)
674
- file = database_path
675
- content = File.exist?(file) ? file.read_file : ''
676
- dirs = content.split(/\n/)
677
- dirs.push(File.expand_path(todo_file))
678
- dirs.sort!.uniq!
679
- File.open(file, 'w') { |f| f.puts dirs.join("\n") }
682
+ NA::Benchmark.measure('save_working_dir') do
683
+ file = database_path
684
+ content = File.exist?(file) ? file.read_file : ''
685
+ dirs = content.split(/\n/)
686
+ dirs.push(File.expand_path(todo_file))
687
+ dirs.sort!.uniq!
688
+ File.open(file, 'w') { |f| f.puts dirs.join("\n") }
689
+ end
680
690
  end
681
691
 
682
692
  ##
data/lib/na/pager.rb CHANGED
@@ -29,38 +29,37 @@ module NA
29
29
  return
30
30
  end
31
31
 
32
+ # Skip pagination for small outputs (faster than starting a pager)
33
+ if text.length < 2000 && text.lines.count < 50
34
+ puts text
35
+ return
36
+ end
37
+
32
38
  pager = which_pager
39
+ return false unless pager
33
40
 
41
+ # Optimized pager execution - use spawn instead of fork+exec
34
42
  read_io, write_io = IO.pipe
35
43
 
36
- input = $stdin
37
-
38
- pid = Kernel.fork do
39
- write_io.close
40
- input.reopen(read_io)
41
- read_io.close
42
-
43
- # Wait until we have input before we start the pager
44
- IO.select [input]
45
-
46
- begin
47
- NA.notify("#{NA.theme[:debug]}Pager #{pager}", debug: true)
48
- exec(pager)
49
- rescue SystemCallError => e
50
- raise Errors::DoingStandardError, "Pager error, #{e}"
51
- end
52
- end
44
+ # Use spawn for better performance than fork+exec
45
+ pid = spawn(pager, in: read_io, out: $stdout, err: $stderr)
46
+ read_io.close
53
47
 
54
48
  begin
55
- read_io.close
49
+ # Write data to pager
56
50
  write_io.write(text)
57
51
  write_io.close
58
- rescue SystemCallError # => e
59
- # raise Errors::DoingStandardError, "Pager error, #{e}"
60
- end
61
52
 
62
- _, status = Process.waitpid2(pid)
63
- status.success?
53
+ # Wait for pager to complete
54
+ _, status = Process.waitpid2(pid)
55
+ status.success?
56
+ rescue SystemCallError => e
57
+ # Clean up on error
58
+ write_io.close rescue nil
59
+ Process.kill('TERM', pid) rescue nil
60
+ Process.waitpid(pid) rescue nil
61
+ false
62
+ end
64
63
  end
65
64
 
66
65
  private
@@ -89,6 +88,11 @@ module NA
89
88
  def which_pager
90
89
  @which_pager ||= find_executable(*pagers)
91
90
  end
91
+
92
+ # Clear pager cache (useful for testing)
93
+ def clear_pager_cache
94
+ @which_pager = nil
95
+ end
92
96
  end
93
97
  end
94
98
  end
data/lib/na/string.rb CHANGED
@@ -355,6 +355,7 @@ class ::String
355
355
  date_string = 'today' if date_string.match(REGEX_DAY) && now.strftime('%a') =~ /^#{Regexp.last_match(1)}/i
356
356
  date_string = "#{options[:context].to_s} #{date_string}" if date_string =~ REGEX_TIME && options[:context]
357
357
 
358
+ require 'chronic' unless defined?(Chronic)
358
359
  res = Chronic.parse(date_string, {
359
360
  guess: options.fetch(:guess, :begin),
360
361
  context: options.fetch(:future, false) ? :future : :past,
data/lib/na/theme.rb CHANGED
@@ -23,8 +23,9 @@ module NA
23
23
  end
24
24
 
25
25
  def load_theme(template: {})
26
- # Default colorization, can be overridden with full or partial template variable
27
- default_template = {
26
+ NA::Benchmark.measure('Theme.load_theme') do
27
+ # Default colorization, can be overridden with full or partial template variable
28
+ default_template = {
28
29
  parent: '{c}',
29
30
  bracket: '{dc}',
30
31
  parent_divider: '{xw}/',
@@ -58,14 +59,15 @@ module NA
58
59
  else
59
60
  {}
60
61
  end
61
- theme = default_template.deep_merge(theme)
62
+ theme = default_template.deep_merge(theme)
62
63
 
63
- File.open(theme_file, 'w') do |f|
64
- f.puts template_help.comment
65
- f.puts YAML.dump(theme)
66
- end
64
+ File.open(theme_file, 'w') do |f|
65
+ f.puts template_help.comment
66
+ f.puts YAML.dump(theme)
67
+ end
67
68
 
68
- theme.merge(template)
69
+ theme.merge(template)
70
+ end
69
71
  end
70
72
  end
71
73
  end
data/lib/na/todo.rb CHANGED
@@ -27,21 +27,22 @@ module NA
27
27
  ## @option file_path [String] file path to parse
28
28
  ##
29
29
  def parse(options)
30
- defaults = {
31
- depth: 1,
32
- done: false,
33
- file_path: nil,
34
- negate: false,
35
- project: nil,
36
- query: nil,
37
- regex: false,
38
- require_na: true,
39
- search: nil,
40
- search_note: true,
41
- tag: nil
42
- }
43
-
44
- settings = defaults.merge(options)
30
+ NA::Benchmark.measure('Todo.parse') do
31
+ defaults = {
32
+ depth: 1,
33
+ done: false,
34
+ file_path: nil,
35
+ negate: false,
36
+ project: nil,
37
+ query: nil,
38
+ regex: false,
39
+ require_na: true,
40
+ search: nil,
41
+ search_note: true,
42
+ tag: nil
43
+ }
44
+
45
+ settings = defaults.merge(options)
45
46
 
46
47
  actions = NA::Actions.new
47
48
  required = []
@@ -87,6 +88,11 @@ module NA
87
88
  end
88
89
  end
89
90
 
91
+ # Pre-compile regexes for better performance
92
+ optional = optional.map { |rx| rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE) }
93
+ required = required.map { |rx| rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE) }
94
+ negated = negated.map { |rx| rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE) }
95
+
90
96
  files = if !settings[:file_path].nil?
91
97
  [settings[:file_path]]
92
98
  elsif settings[:query].nil?
@@ -96,14 +102,21 @@ module NA
96
102
  end
97
103
 
98
104
  NA.notify("Files: #{files.join(', ')}", debug: true)
99
- files.each do |file|
100
- NA.save_working_dir(File.expand_path(file))
101
- content = file.read_file
102
- indent_level = 0
103
- parent = []
104
- in_yaml = false
105
- in_action = false
106
- content.split(/\n/).each.with_index do |line, idx|
105
+ # Cache project regex compilation outside the line loop for better performance
106
+ project_regex = if settings[:project]
107
+ rx = settings[:project].split(%r{[/:]}).join('.*?/')
108
+ Regexp.new("#{rx}.*?", Regexp::IGNORECASE)
109
+ end
110
+
111
+ files.each do |file|
112
+ NA::Benchmark.measure("Parse file: #{File.basename(file)}") do
113
+ NA.save_working_dir(File.expand_path(file))
114
+ content = file.read_file
115
+ indent_level = 0
116
+ parent = []
117
+ in_yaml = false
118
+ in_action = false
119
+ content.split(/\n/).each.with_index do |line, idx|
107
120
  if in_yaml && line !~ /^(---|~~~)\s*$/
108
121
  NA.notify("YAML: #{line}", debug: true)
109
122
  elsif line =~ /^(---|~~~)\s*$/
@@ -130,20 +143,22 @@ module NA
130
143
  elsif line.action?
131
144
  in_action = false
132
145
 
133
- action = line.action
134
- new_action = NA::Action.new(file, File.basename(file, ".#{NA.extension}"), parent.dup, action, idx)
135
-
136
- projects[-1].last_line = idx if projects.count.positive?
137
-
146
+ # Early exits before creating Action object
138
147
  next if line.done? && !settings[:done]
139
148
 
140
149
  next if settings[:require_na] && !line.na?
141
150
 
142
- if settings[:project]
143
- rx = settings[:project].split(%r{[/:]}).join('.*?/')
144
- next unless parent.join('/') =~ Regexp.new("#{rx}.*?", Regexp::IGNORECASE)
151
+ if project_regex
152
+ next unless parent.join('/') =~ project_regex
145
153
  end
146
154
 
155
+ # Only create Action if we passed basic filters
156
+ action = line.action
157
+ new_action = NA::Action.new(file, File.basename(file, ".#{NA.extension}"), parent.dup, action, idx)
158
+
159
+ projects[-1].last_line = idx if projects.count.positive?
160
+
161
+ # Tag matching
147
162
  has_tag = !optional_tag.empty? || !required_tag.empty? || !negated_tag.empty?
148
163
  next if has_tag && !new_action.tags_match?(any: optional_tag,
149
164
  all: required_tag,
@@ -155,19 +170,23 @@ module NA
155
170
  actions[-1].note.push(line.strip) if actions.count.positive?
156
171
  projects[-1].last_line = idx if projects.count.positive?
157
172
  end
173
+ end
174
+ projects = projects.dup
175
+ end
158
176
  end
159
- projects = projects.dup
160
- end
161
177
 
162
- actions.delete_if do |new_action|
163
- has_search = !optional.empty? || !required.empty? || !negated.empty?
164
- has_search && !new_action.search_match?(any: optional,
165
- all: required,
166
- none: negated,
167
- include_note: settings[:search_note])
168
- end
178
+ NA::Benchmark.measure('Filter actions by search') do
179
+ actions.delete_if do |new_action|
180
+ has_search = !optional.empty? || !required.empty? || !negated.empty?
181
+ has_search && !new_action.search_match?(any: optional,
182
+ all: required,
183
+ none: negated,
184
+ include_note: settings[:search_note])
185
+ end
186
+ end
169
187
 
170
- [files, actions, projects]
188
+ [files, actions, projects]
189
+ end
171
190
  end
172
191
 
173
192
  def parse_search(tag, negate)
data/lib/na/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Na
2
- VERSION = '1.2.79'
2
+ VERSION = '1.2.80'
3
3
  end
data/lib/na.rb CHANGED
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'na/benchmark'
3
4
  require 'na/version'
4
5
  require 'na/pager'
5
6
  require 'time'
6
7
  require 'fileutils'
7
8
  require 'shellwords'
8
- require 'chronic'
9
+ # Lazy load heavy gems - only load when needed
10
+ # require 'chronic' # Loaded in action.rb and string.rb when needed
9
11
  require 'tty-screen'
10
12
  require 'tty-reader'
11
13
  require 'tty-which'
data/src/_README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  _If you're one of the rare people like me who find this useful, feel free to
10
10
  [buy me some coffee][donate]._
11
11
 
12
- The current version of `na` is <!--VER-->1.2.78<!--END VER-->.
12
+ The current version of `na` is <!--VER-->1.2.79<!--END VER-->.
13
13
 
14
14
  `na` ("next action") is a command line tool designed to make it easy to see what your next actions are for any project, right from the command line. It works with TaskPaper-formatted files (but any plain text format will do), looking for `@na` tags (or whatever you specify) in todo files in your current folder.
15
15
 
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Simple performance test script that doesn't require bundler
5
+ require_relative 'lib/na/benchmark'
6
+
7
+ # Mock the required dependencies
8
+ module NA
9
+ module Color
10
+ def self.template(input)
11
+ input.to_s # Simple mock
12
+ end
13
+ end
14
+
15
+ module Theme
16
+ def self.load_theme
17
+ {
18
+ parent: '{c}',
19
+ bracket: '{dc}',
20
+ parent_divider: '{xw}/',
21
+ action: '{bg}',
22
+ project: '{xbk}',
23
+ templates: {
24
+ output: '%parent%action',
25
+ default: '%parent%action'
26
+ }
27
+ }
28
+ end
29
+ end
30
+
31
+ def self.theme
32
+ @theme ||= Theme.load_theme
33
+ end
34
+
35
+ def self.notify(msg, debug: false)
36
+ puts msg if debug
37
+ end
38
+ end
39
+
40
+ # Initialize benchmark
41
+ NA::Benchmark.init
42
+
43
+ # Test the optimizations
44
+ puts "Testing performance optimizations..."
45
+
46
+ # Test 1: Theme caching
47
+ NA::Benchmark.measure('Theme loading (first time)') do
48
+ theme1 = NA::Theme.load_theme
49
+ end
50
+
51
+ NA::Benchmark.measure('Theme loading (cached)') do
52
+ theme2 = NA.theme
53
+ end
54
+
55
+ # Test 2: Color template caching
56
+ NA::Benchmark.measure('Color template (first time)') do
57
+ NA::Color.template('{bg}Test action{x}')
58
+ end
59
+
60
+ NA::Benchmark.measure('Color template (cached)') do
61
+ NA::Color.template('{bg}Test action{x}')
62
+ end
63
+
64
+ # Test 3: Multiple operations
65
+ NA::Benchmark.measure('Multiple theme calls') do
66
+ 100.times do
67
+ NA.theme
68
+ end
69
+ end
70
+
71
+ NA::Benchmark.measure('Multiple color templates') do
72
+ 100.times do
73
+ NA::Color.template('{bg}Action {c}#{rand(1000)}{x}')
74
+ end
75
+ end
76
+
77
+ # Report results
78
+ NA::Benchmark.report
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: na
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.79
4
+ version: 1.2.80
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brett Terpstra
@@ -261,6 +261,7 @@ files:
261
261
  - lib/na/action.rb
262
262
  - lib/na/actions.rb
263
263
  - lib/na/array.rb
264
+ - lib/na/benchmark.rb
264
265
  - lib/na/colors.rb
265
266
  - lib/na/editor.rb
266
267
  - lib/na/hash.rb
@@ -281,6 +282,7 @@ files:
281
282
  - src/_README.md
282
283
  - test.md
283
284
  - test2.txt
285
+ - test_performance.rb
284
286
  homepage: https://brettterpstra.com/projects/na/
285
287
  licenses:
286
288
  - MIT