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/pager.rb CHANGED
@@ -7,78 +7,98 @@ module NA
7
7
  module Pager
8
8
  class << self
9
9
  # Boolean determines whether output is paginated
10
+ #
11
+ # @return [Boolean] true if paginated
10
12
  def paginate
11
13
  @paginate ||= false
12
14
  end
13
15
 
14
16
  # Enable/disable pagination
15
17
  #
16
- # @param should_paginate [Boolean] true to paginate
17
- def paginate=(should_paginate)
18
- @paginate = should_paginate
19
- end
18
+ # @return [void]
19
+ attr_writer :paginate
20
20
 
21
- # Page output. If @paginate is false, just dump to
22
- # STDOUT
23
- #
24
- # @param text [String] text to paginate
21
+ # Page output. If @paginate is false, just dump to STDOUT
25
22
  #
23
+ # @param text [String] text to paginate
24
+ # @return [Boolean, nil] true if paged, false if not, nil if no pager
26
25
  def page(text)
27
26
  unless @paginate
28
27
  puts text
29
28
  return
30
29
  end
31
30
 
31
+ # Skip pagination for small outputs (faster than starting a pager)
32
+ if text.length < 2000 && text.lines.count < 50
33
+ puts text
34
+ return
35
+ end
36
+
32
37
  pager = which_pager
38
+ return false unless pager
33
39
 
40
+ # Optimized pager execution - use spawn instead of fork+exec
34
41
  read_io, write_io = IO.pipe
35
42
 
36
- input = $stdin
43
+ # Use spawn for better performance than fork+exec
44
+ pid = spawn(pager, in: read_io, out: $stdout, err: $stderr)
45
+ read_io.close
37
46
 
38
- pid = Kernel.fork do
47
+ begin
48
+ # Write data to pager
49
+ write_io.write(text)
39
50
  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
51
 
52
+ # Wait for pager to complete
53
+ _, status = Process.waitpid2(pid)
54
+ status.success?
55
+ rescue SystemCallError
56
+ # Clean up on error
46
57
  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}"
58
+ write_io.close
59
+ rescue StandardError
60
+ nil
51
61
  end
62
+ begin
63
+ Process.kill('TERM', pid)
64
+ rescue StandardError
65
+ nil
66
+ end
67
+ begin
68
+ Process.waitpid(pid)
69
+ rescue StandardError
70
+ nil
71
+ end
72
+ false
52
73
  end
53
-
54
- begin
55
- read_io.close
56
- write_io.write(text)
57
- write_io.close
58
- rescue SystemCallError # => e
59
- # raise Errors::DoingStandardError, "Pager error, #{e}"
60
- end
61
-
62
- _, status = Process.waitpid2(pid)
63
- status.success?
64
74
  end
65
75
 
66
76
  private
67
77
 
78
+ # Get the git pager command if available
79
+ #
80
+ # @return [String, nil] git pager command
68
81
  def git_pager
69
82
  TTY::Which.exist?('git') ? `#{TTY::Which.which('git')} config --get-all core.pager` : nil
70
83
  end
71
84
 
85
+ # List of possible pager commands
86
+ #
87
+ # @return [Array<String>] pager commands
72
88
  def pagers
73
89
  [
74
- ENV['PAGER'],
90
+ ENV.fetch('PAGER', nil),
75
91
  'less -FXr',
76
- ENV['GIT_PAGER'],
92
+ ENV.fetch('GIT_PAGER', nil),
77
93
  git_pager,
78
94
  'more -r'
79
95
  ].remove_bad
80
96
  end
81
97
 
98
+ # Find the first available executable pager command
99
+ #
100
+ # @param commands [Array<String>] commands to check
101
+ # @return [String, nil] first available command
82
102
  def find_executable(*commands)
83
103
  execs = commands.empty? ? pagers : commands
84
104
  execs
@@ -86,9 +106,17 @@ module NA
86
106
  .find { |cmd| TTY::Which.exist?(cmd.split.first) }
87
107
  end
88
108
 
109
+ # Determine which pager to use
110
+ #
111
+ # @return [String, nil] pager command
89
112
  def which_pager
90
113
  @which_pager ||= find_executable(*pagers)
91
114
  end
115
+
116
+ # Clear pager cache (useful for testing)
117
+ def clear_pager_cache
118
+ @which_pager = nil
119
+ end
92
120
  end
93
121
  end
94
122
  end
data/lib/na/project.rb CHANGED
@@ -4,6 +4,13 @@ module NA
4
4
  class Project < Hash
5
5
  attr_accessor :project, :indent, :line, :last_line
6
6
 
7
+ # Initialize a Project object
8
+ #
9
+ # @param project [String] Project name
10
+ # @param indent [Integer] Indentation level
11
+ # @param line [Integer] Starting line number
12
+ # @param last_line [Integer] Ending line number
13
+ # @return [void]
7
14
  def initialize(project, indent = 0, line = 0, last_line = 0)
8
15
  super()
9
16
  @project = project
@@ -12,17 +19,23 @@ module NA
12
19
  @last_line = last_line
13
20
  end
14
21
 
22
+ # String representation of the project
23
+ #
24
+ # @return [String]
15
25
  def to_s
16
26
  { project: @project, indent: @indent, line: @line, last_line: @last_line }.to_s
17
27
  end
18
28
 
29
+ # Inspect the project object
30
+ #
31
+ # @return [String]
19
32
  def inspect
20
33
  [
21
34
  "@project: #{@project}",
22
35
  "@indent: #{@indent}",
23
36
  "@line: #{@line}",
24
37
  "@last_line: #{@last_line}"
25
- ].join(" ")
38
+ ].join(' ')
26
39
  end
27
40
  end
28
41
  end
data/lib/na/prompt.rb CHANGED
@@ -4,6 +4,10 @@ module NA
4
4
  # Prompt Hooks
5
5
  module Prompt
6
6
  class << self
7
+ # Generate the shell prompt hook script for na
8
+ #
9
+ # @param shell [Symbol] Shell type (:zsh, :fish, :bash)
10
+ # @return [String] Shell script for prompt hook
7
11
  def prompt_hook(shell)
8
12
  case shell
9
13
  when :zsh
@@ -14,7 +18,9 @@ module NA
14
18
  when :tag
15
19
  'na tagged $(basename "$PWD")'
16
20
  else
17
- NA.notify("#{NA.theme[:error]}When using a global file, a prompt hook requires `--cwd_as [tag|project]`", exit_code: 1)
21
+ NA.notify(
22
+ "#{NA.theme[:error]}When using a global file, a prompt hook requires `--cwd_as [tag|project]`", exit_code: 1
23
+ )
18
24
  end
19
25
  else
20
26
  'na next'
@@ -31,7 +37,9 @@ module NA
31
37
  when :tag
32
38
  'na tagged (basename "$PWD")'
33
39
  else
34
- NA.notify("#{NA.theme[:error]}When using a global file, a prompt hook requires `--cwd_as [tag|project]`", exit_code: 1)
40
+ NA.notify(
41
+ "#{NA.theme[:error]}When using a global file, a prompt hook requires `--cwd_as [tag|project]`", exit_code: 1
42
+ )
35
43
  end
36
44
  else
37
45
  'na next'
@@ -50,7 +58,9 @@ module NA
50
58
  when :tag
51
59
  'na tagged $(basename "$PWD")'
52
60
  else
53
- NA.notify("#{NA.theme[:error]}When using a global file, a prompt hook requires `--cwd_as [tag|project]`", exit_code: 1)
61
+ NA.notify(
62
+ "#{NA.theme[:error]}When using a global file, a prompt hook requires `--cwd_as [tag|project]`", exit_code: 1
63
+ )
54
64
  end
55
65
  else
56
66
  'na next'
@@ -70,6 +80,10 @@ module NA
70
80
  end
71
81
  end
72
82
 
83
+ # Get the configuration file path for the given shell
84
+ #
85
+ # @param shell [Symbol] Shell type
86
+ # @return [String] Path to shell config file
73
87
  def prompt_file(shell)
74
88
  files = {
75
89
  zsh: '~/.zshrc',
@@ -80,6 +94,10 @@ module NA
80
94
  files[shell]
81
95
  end
82
96
 
97
+ # Display the prompt hook script and notify user of config file
98
+ #
99
+ # @param shell [Symbol] Shell type
100
+ # @return [void]
83
101
  def show_prompt_hook(shell)
84
102
  file = prompt_file(shell)
85
103
 
@@ -87,6 +105,10 @@ module NA
87
105
  puts prompt_hook(shell)
88
106
  end
89
107
 
108
+ # Install the prompt hook script into the shell config file
109
+ #
110
+ # @param shell [Symbol] Shell type
111
+ # @return [void]
90
112
  def install_prompt_hook(shell)
91
113
  file = prompt_file(shell)
92
114
 
data/lib/na/string.rb CHANGED
@@ -6,30 +6,20 @@ REGEX_TIME = /^#{REGEX_CLOCK}$/i.freeze
6
6
 
7
7
  # String helpers
8
8
  class ::String
9
- ##
10
- ## Insert a comment character at the start of every line
11
- ##
12
- ## @param char [String] The character to insert (default #)
13
- ##
14
- def comment(char = "#")
15
- split(/\n/).map { |l| "# #{l}" }.join("\n")
9
+ # Insert a comment character at the start of every line
10
+ # @param char [String] The character to insert (default #)
11
+ def comment(_char = '#')
12
+ split("\n").map { |l| "# #{l}" }.join("\n")
16
13
  end
17
14
 
18
- ##
19
- ## Tests if object is nil or empty
20
- ##
21
- ## @return [Boolean] true if object is defined and
22
- ## has content
23
- ##
15
+ # Tests if object is nil or empty
16
+ # @return [Boolean] true if object is defined and has content
24
17
  def good?
25
18
  !strip.empty?
26
19
  end
27
20
 
28
- ##
29
- ## Test if line should be ignored
30
- ##
31
- ## @return [Boolean] line is empty or comment
32
- ##
21
+ # Test if line should be ignored
22
+ # @return [Boolean] line is empty or comment
33
23
  def ignore?
34
24
  line = self
35
25
  line =~ /^#/ || line.strip.empty?
@@ -50,19 +40,16 @@ class ::String
50
40
  end
51
41
 
52
42
  # IO.read(file).force_encoding('ASCII-8BIT').encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
53
- IO.read(file).force_encoding('utf-8')
43
+ File.read(file).force_encoding('utf-8')
54
44
  end
55
45
 
56
- ##
57
- ## Determine indentation level of line
58
- ##
59
- ## @return [Number] number of indents detected
60
- ##
46
+ # Determine indentation level of line
47
+ # @return [Number] number of indents detected
61
48
  def indent_level
62
49
  prefix = match(/(^[ \t]+)/)
63
50
  return 0 if prefix.nil?
64
51
 
65
- prefix[1].gsub(/ /, "\t").scan(/\t/).count
52
+ prefix[1].gsub(' ', "\t").scan("\t").count
66
53
  end
67
54
 
68
55
  def action?
@@ -94,31 +81,22 @@ class ::String
94
81
  self =~ /@#{NA.na_tag}\b/
95
82
  end
96
83
 
97
- ##
98
- ## Colorize the dirname and filename of a path
99
- ##
100
- ## @return Colorized string
101
- ##
84
+ # Colorize the dirname and filename of a path
85
+ # @return [String] Colorized string
102
86
  def highlight_filename
103
87
  dir = File.dirname(self).shorten_path.trunc_middle(TTY::Screen.columns / 3)
104
88
  file = NA.include_ext ? File.basename(self) : File.basename(self, ".#{NA.extension}")
105
89
  "#{NA.theme[:dirname]}#{dir}/#{NA.theme[:filename]}#{file}{x}"
106
90
  end
107
91
 
108
- ##
109
- ## Colorize @tags with ANSI escapes
110
- ##
111
- ## @param color [String] color (see #Color)
112
- ## @param value [String] The value color
113
- ## template
114
- ## @param parens [String] The parens color
115
- ## template
116
- ## @param last_color [String] Color to restore after
117
- ## tag highlight
118
- ##
119
- ## @return [String] string with @tags highlighted
120
- ##
121
- def highlight_tags(color: NA.theme[:tags], value: NA.theme[:value], parens: NA.theme[:value_parens], last_color: NA.theme[:action])
92
+ # Colorize @tags with ANSI escapes
93
+ # @param color [String] color (see #Color)
94
+ # @param value [String] The value color template
95
+ # @param parens [String] The parens color template
96
+ # @param last_color [String] Color to restore after tag highlight
97
+ # @return [String] string with @tags highlighted
98
+ def highlight_tags(color: NA.theme[:tags], value: NA.theme[:value], parens: NA.theme[:value_parens],
99
+ last_color: NA.theme[:action])
122
100
  tag_color = NA::Color.template(color)
123
101
  paren_color = NA::Color.template(parens)
124
102
  value_color = NA::Color.template(value)
@@ -132,21 +110,16 @@ class ::String
132
110
  end
133
111
  end
134
112
 
135
- ##
136
- ## Highlight search results
137
- ##
138
- ## @param regexes [Array] The regexes for the
139
- ## search
140
- ## @param color [String] The highlight color
141
- ## template
142
- ## @param last_color [String] Color to restore after
143
- ## highlight
144
- ##
113
+ # Highlight search results
114
+ # @param regexes [Array] The regexes for the search
115
+ # @param color [String] The highlight color template
116
+ # @param last_color [String] Color to restore after highlight
145
117
  def highlight_search(regexes, color: NA.theme[:search_highlight], last_color: NA.theme[:action])
146
118
  string = dup
147
119
  color = NA::Color.template(color.dup)
148
120
  regexes.each do |rx|
149
121
  next if rx.nil?
122
+
150
123
  rx = Regexp.new(rx, Regexp::IGNORECASE) if rx.is_a?(String)
151
124
 
152
125
  string.gsub!(rx) do
@@ -158,16 +131,23 @@ class ::String
158
131
  string
159
132
  end
160
133
 
134
+ # Truncate the string in the middle, replacing the removed section with '[...]'.
135
+ # @param max [Integer] Maximum allowed length of the string
136
+ # @return [String] Truncated string with middle replaced if necessary
161
137
  def trunc_middle(max)
162
138
  return self unless length > max
163
139
 
164
140
  half = (max / 2).floor - 3
165
- chars = split('')
141
+ chars = chars
166
142
  pre = chars.slice(0, half)
167
143
  post = chars.reverse.slice(0, half).reverse
168
- "#{pre.join('')}[...]#{post.join('')}"
144
+ "#{pre.join}[...]#{post.join}"
169
145
  end
170
146
 
147
+ # Wrap the string to a given width, indenting each line and preserving tag formatting.
148
+ # @param width [Integer] The maximum line width
149
+ # @param indent [Integer] Number of spaces to indent each line
150
+ # @return [String] Wrapped string
171
151
  def wrap(width, indent)
172
152
  return "\n#{self}" if width <= 80
173
153
 
@@ -188,40 +168,33 @@ class ::String
188
168
  end
189
169
  end
190
170
  output << line.join(' ')
191
- output.join("\n" + ' ' * (indent + 2)).gsub(/†/, ' ')
171
+ output.join("\n#{' ' * (indent + 2)}").gsub(/†/, ' ')
192
172
  end
193
173
 
194
174
  # Returns the last escape sequence from a string.
195
- #
196
- # @note Actually returns all escape codes, with the
197
- # assumption that the result of inserting them
198
- # will generate the same color as was set at
199
- # the end of the string. Because you can send
200
- # modifiers like dark and bold separate from
201
- # color codes, only using the last code may
202
- # not render the same style.
203
- #
204
- # @return [String] All escape codes in string
205
- #
175
+ # @note Actually returns all escape codes, with the assumption that the result of inserting them will generate the same color as was set at end of the string. Because you can send modifiers like dark and bold separate from color codes, only using the last code may not render the same style.
176
+ # @return [String] All escape codes in string
206
177
  def last_color
207
- scan(/\e\[[\d;]+m/).join('').gsub(/\e\[0m/, '')
178
+ scan(/\e\[[\d;]+m/).join.gsub("\e[0m", '')
208
179
  end
209
180
 
210
- ##
211
- ## Convert a directory path to a regular expression
212
- ##
213
- ## @note Splits at / or :, adds variable distance
214
- ## between characters, joins segments with
215
- ## slashes and requires that last segment
216
- ## match last segment of target path
217
- ##
218
- ## @param distance The distance allowed between characters
219
- ## @param require_last Require match to be last element in path
220
- ##
181
+ # Convert a directory path to a regular expression
182
+ # @note Splits at / or :, adds variable distance between characters, joins segments with slashes and requires that last segment match last segment of target path
183
+ # @param distance [Integer] The distance allowed between characters
184
+ # @param require_last [Boolean] Require match to be last element in path
221
185
  def dir_to_rx(distance: 1, require_last: true)
222
- "#{split(%r{[/:]}).map { |comp| comp.split('').join(".{0,#{distance}}").gsub(/\*/, '[^ ]*?') }.join('.*?/.*?')}#{require_last ? '[^/]*?$' : ''}"
186
+ "#{split(%r{[/:]}).map do |comp|
187
+ comp.chars.join(".{0,#{distance}}").gsub('*', '[^ ]*?')
188
+ end.join('.*?/.*?')}#{require_last ? '[^/]*?$' : ''}"
223
189
  end
224
190
 
191
+ # Check if the string matches directory patterns using any, all, and none criteria.
192
+ # @param any [Array] Patterns where any match is sufficient
193
+ # @param all [Array] Patterns where all must match
194
+ # @param none [Array] Patterns where none must match
195
+ # @param require_last [Boolean] Require last segment match
196
+ # @param distance [Integer] Allowed character distance in regex
197
+ # @return [Boolean] True if matches criteria
225
198
  def dir_matches(any: [], all: [], none: [], require_last: true, distance: 1)
226
199
  any_rx = any.map { |q| q.dir_to_rx(distance: distance, require_last: require_last) }
227
200
  all_rx = all.map { |q| q.dir_to_rx(distance: distance, require_last: require_last) }
@@ -229,29 +202,29 @@ class ::String
229
202
  matches_any(any_rx) && matches_all(all_rx) && matches_none(none_rx)
230
203
  end
231
204
 
205
+ # Check if the string matches any, all, and none regex patterns.
206
+ # @param any [Array] Patterns where any match is sufficient
207
+ # @param all [Array] Patterns where all must match
208
+ # @param none [Array] Patterns where none must match
209
+ # @return [Boolean] True if matches criteria
232
210
  def matches(any: [], all: [], none: [])
233
211
  matches_any(any) && matches_all(all) && matches_none(none)
234
212
  end
235
213
 
236
- ##
237
- ## Convert wildcard characters to regular expressions
238
- ##
239
- ## @return [String] Regex string
240
- ##
214
+ # Convert wildcard characters to regular expressions
215
+ # @return [String] Regex string
241
216
  def wildcard_to_rx
242
- gsub(/\./, '\\.').gsub(/\?/, '.').gsub(/\*/, '[^ ]*?')
217
+ gsub('.', '\\.').gsub('?', '.').gsub('*', '[^ ]*?')
243
218
  end
244
219
 
220
+ # Capitalize the first character of the string in place.
221
+ # @return [String] The modified string
245
222
  def cap_first!
246
223
  replace cap_first
247
224
  end
248
225
 
249
- ##
250
- ## Capitalize first character, leaving other
251
- ## capitalization in place
252
- ##
253
- ## @return [String] capitalized string
254
- ##
226
+ # Capitalize first character, leaving other capitalization in place
227
+ # @return [String] capitalized string
255
228
  def cap_first
256
229
  sub(/^([a-z])(.*)$/) do
257
230
  m = Regexp.last_match
@@ -259,24 +232,14 @@ class ::String
259
232
  end
260
233
  end
261
234
 
262
- ##
263
- ## Replace home directory with tilde
264
- ##
265
- ## @return [String] shortened path
266
- ##
235
+ # Replace home directory with tilde
236
+ # @return [String] shortened path
267
237
  def shorten_path
268
- sub(/^#{ENV['HOME']}/, '~')
238
+ sub(/^#{Dir.home}/, '~')
269
239
  end
270
240
 
271
- ##
272
- ## Convert (chronify) natural language dates
273
- ## within configured date tags (tags whose value is
274
- ## expected to be a date). Modifies string in place.
275
- ##
276
- ## @param additional_tags [Array] An array of
277
- ## additional tags to
278
- ## consider date_tags
279
- ##
241
+ # Convert (chronify) natural language dates within configured date tags (tags whose value is expected to be a date). Modifies string in place.
242
+ # @param additional_tags [Array] An array of additional tags to consider date_tags
280
243
  def expand_date_tags(additional_tags = nil)
281
244
  iso_rx = /\d{4}-\d\d-\d\d \d\d:\d\d/
282
245
 
@@ -312,27 +275,14 @@ class ::String
312
275
  end
313
276
  end
314
277
 
315
- ##
316
- ## Converts input string into a Time object when input
317
- ## takes on the following formats:
318
- ## - interval format e.g. '1d2h30m', '45m'
319
- ## etc.
320
- ## - a semantic phrase e.g. 'yesterday
321
- ## 5:30pm'
322
- ## - a strftime e.g. '2016-03-15 15:32:04
323
- ## PDT'
324
- ##
325
- ## @param options Additional options
326
- ##
327
- ## @option options :future [Boolean] assume future date
328
- ## (default: false)
329
- ##
330
- ## @option options :guess [Symbol] :begin or :end to
331
- ## assume beginning or end of
332
- ## arbitrary time range
333
- ##
334
- ## @return [DateTime] result
335
- ##
278
+ # Converts input string into a Time object when input takes on the following formats:
279
+ # - interval format e.g. '1d2h30m', '45m' etc.
280
+ # - a semantic phrase e.g. 'yesterday 5:30pm'
281
+ # - a strftime e.g. '2016-03-15 15:32:04 PDT'
282
+ # @param options [Hash] Additional options
283
+ # @option options :future [Boolean] assume future date (default: false)
284
+ # @option options :guess [Symbol] :begin or :end to assume beginning or end of arbitrary time range
285
+ # @return [DateTime] result
336
286
  def chronify(**options)
337
287
  now = Time.now
338
288
  raise StandardError, "Invalid time expression #{inspect}" if to_s.strip == ''
@@ -353,8 +303,9 @@ class ::String
353
303
  else
354
304
  date_string = dup
355
305
  date_string = 'today' if date_string.match(REGEX_DAY) && now.strftime('%a') =~ /^#{Regexp.last_match(1)}/i
356
- date_string = "#{options[:context].to_s} #{date_string}" if date_string =~ REGEX_TIME && options[:context]
306
+ date_string = "#{options[:context]} #{date_string}" if date_string =~ REGEX_TIME && options[:context]
357
307
 
308
+ require 'chronic' unless defined?(Chronic)
358
309
  res = Chronic.parse(date_string, {
359
310
  guess: options.fetch(:guess, :begin),
360
311
  context: options.fetch(:future, false) ? :future : :past,
@@ -367,8 +318,12 @@ class ::String
367
318
  res
368
319
  end
369
320
 
321
+ # Private helper methods for pattern matching
370
322
  private
371
323
 
324
+ # Returns true if none of the regexes match the string.
325
+ # @param regexes [Array] Array of regex patterns
326
+ # @return [Boolean] True if none match
372
327
  def matches_none(regexes)
373
328
  regexes.each do |rx|
374
329
  return false if match(Regexp.new(rx, Regexp::IGNORECASE))
@@ -376,6 +331,9 @@ class ::String
376
331
  true
377
332
  end
378
333
 
334
+ # Returns true if any of the regexes match the string.
335
+ # @param regexes [Array] Array of regex patterns
336
+ # @return [Boolean] True if any match
379
337
  def matches_any(regexes)
380
338
  regexes.each do |rx|
381
339
  return true if match(Regexp.new(rx, Regexp::IGNORECASE))
@@ -383,6 +341,9 @@ class ::String
383
341
  false
384
342
  end
385
343
 
344
+ # Returns true if all of the regexes match the string.
345
+ # @param regexes [Array] Array of regex patterns
346
+ # @return [Boolean] True if all match
386
347
  def matches_all(regexes)
387
348
  regexes.each do |rx|
388
349
  return false unless match(Regexp.new(rx, Regexp::IGNORECASE))