salad 0.0.1

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.
@@ -0,0 +1,503 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'salad'
4
+ require_relative 'gem_help'
5
+ require_relative 'bond_completer'
6
+
7
+ module Salad
8
+ HINTS = [
9
+ "TAB completes service and gem names: `Salad.nas<TAB>` → Salad.nasturtium",
10
+ "TAB inside parens shows endpoint parameters: `Salad.inaturalist.observations(<TAB>`",
11
+ "Results render in `less`, press `q` to exit paged API results",
12
+ "Results render in `less`, press `/` to search within paged API results (n/N for next/previous match)",
13
+ "Results render in `less`, use vim-style navigation (j/k, gg/G, h/l) — `man less` is worth a read",
14
+ "See full endpoint docs: `help Salad.nasturtium.observations`",
15
+ "List all services: `help`",
16
+ "Service and gem names are interchangeable: `Salad.inaturalist` == `Salad.nasturtium`",
17
+ "`Salad.catalogue_of_life.*` and `Salad.col.*` auto-scope to the latest COL release (3LR)",
18
+ "Override the COL dataset: `Salad.col.nameusage_search(q: 'Aedes', dataset_id: 'COL25')`",
19
+ "`Salad.clb` and `Salad.checklistbank` search across ALL ChecklistBank datasets, not just COL",
20
+ "Type `hints` any time to see all tips"
21
+ ].freeze
22
+
23
+ def self.console
24
+ # Disable Readline's automatic space appending to completions
25
+ # Salad completions should be chainable without spaces
26
+ require 'readline'
27
+ Readline.completion_append_character = ""
28
+
29
+ Pry.config.prompt = Pry::Prompt.new(
30
+ 'salad',
31
+ 'Salad REPL prompt',
32
+ [->(*) { 'salad> ' }, ->(*) { 'salad* ' }]
33
+ )
34
+
35
+ # Case-insensitive prefix completion for top-level constants:
36
+ # `sala<TAB>` -> `Salad.`, `nast<TAB>` -> `Nasturtium.`, etc.
37
+ # The regex is tight (only matches strings that are actual prefixes of a
38
+ # known name) so unrelated lowercase input like `put<TAB>` falls through
39
+ # to Pry's default object/method completion.
40
+ Bond::M.complete(:on => Salad.completable_prefix_regex, :search => false, :action => lambda { |input|
41
+ prefix = input.line.downcase
42
+ Salad.completable_names
43
+ .select { |n| n.downcase.start_with?(prefix) }
44
+ .map { |n| "#{n}." }
45
+ })
46
+
47
+ # Help-prefixed variant: `help sal<TAB>` -> `help Salad`,
48
+ # `help nast<TAB>` -> `help nasturtium`. Completes to topics the help
49
+ # command actually recognizes (gem/service names and the literal 'Salad'),
50
+ # with no trailing dot so pressing Enter shows the help.
51
+ Bond::M.complete(:on => Salad.help_completable_prefix_regex, :search => false, :action => lambda { |input|
52
+ prefix = input.line.sub(/\Ahelp\s+/i, '').downcase
53
+ Salad.help_completable_names.select { |n| n.downcase.start_with?(prefix) }
54
+ })
55
+
56
+ # Configure Bond completion for Salad services and gems
57
+ # Don't match if there's already a dot (that's handled by endpoint completion)
58
+ salad_gem_pattern = /^(help\s+)?Salad\.(\w*)$/
59
+ Bond::M.complete(:on => salad_gem_pattern, :action => lambda { |input|
60
+ match = input.line.match(salad_gem_pattern)
61
+ if match
62
+ prefix = match[2]
63
+ completions = (Salad::SERVICES.keys.map(&:to_s) + Salad::GEMS)
64
+ .select { |g| g.start_with?(prefix) }
65
+
66
+ if input.line.start_with?('help ')
67
+ # No trailing dot: `help Salad.nasturtium` matches the help command's
68
+ # regex; `help Salad.nasturtium.` would fall through to "no help".
69
+ completions.map { |g| "Salad.#{g}" }
70
+ else
71
+ # Trailing dot for chaining: after `Salad.nast<TAB>`, the user almost
72
+ # always wants to type a method next, so put the cursor past the dot.
73
+ completions.map { |g| "Salad.#{g}." }
74
+ end
75
+ else
76
+ []
77
+ end
78
+ })
79
+
80
+ # Completion for endpoint/method names: "Salad.gem.method" or "Salad.gem .method" (with optional space)
81
+ # Handles cases where Pry adds a space after the gem name completion or around the dot
82
+ salad_endpoint_pattern = /^(help\s+)?Salad\.(\w+)\s*\.\s*(\w*)$/
83
+ Bond::M.complete(:on => salad_endpoint_pattern, :action => lambda { |input|
84
+ match = input.line.match(salad_endpoint_pattern)
85
+ if match
86
+ is_help = !match[1].nil?
87
+ gem_name = match[2]
88
+ method_prefix = match[3]
89
+
90
+ actual_gem_name = if Salad::SERVICES.key?(gem_name.to_sym)
91
+ Salad::SERVICES[gem_name.to_sym]
92
+ elsif Salad::GEMS.include?(gem_name)
93
+ gem_name
94
+ else
95
+ nil
96
+ end
97
+
98
+ if actual_gem_name
99
+ begin
100
+ mod = Salad.gem_module(actual_gem_name)
101
+ gem_sym = actual_gem_name.to_sym
102
+
103
+ if is_help
104
+ help_data = Salad::GEM_HELP[gem_sym]
105
+ if help_data&.[](:endpoints)
106
+ help_data[:endpoints].keys
107
+ .select { |e| e.to_s.start_with?(method_prefix) }
108
+ .map { |e| "Salad.#{gem_name}.#{e}" }
109
+ else
110
+ []
111
+ end
112
+ else
113
+ help_data = Salad::GEM_HELP[gem_sym]
114
+ endpoints = help_data&.[](:endpoints)&.keys&.map(&:to_s) || []
115
+
116
+ begin
117
+ public_methods = mod.methods(false).map(&:to_s)
118
+ rescue
119
+ public_methods = []
120
+ end
121
+
122
+ all_methods = (endpoints + public_methods).uniq
123
+ .select { |m| m.start_with?(method_prefix) }
124
+ .sort
125
+
126
+ all_methods.map { |m| "Salad.#{gem_name}.#{m}" }
127
+ end
128
+ rescue NameError
129
+ []
130
+ end
131
+ else
132
+ []
133
+ end
134
+ else
135
+ []
136
+ end
137
+ })
138
+
139
+ # Completion for raw module access: "Nasturtium.method" or "help Nasturtium.method".
140
+ # Pry's BondCompleter override disables default object-introspection completion,
141
+ # so we explicitly handle the SFG wrapper modules here.
142
+ module_to_gem = Salad::GEMS.each_with_object({}) do |g, h|
143
+ h[g.split('_').map(&:capitalize).join] = g
144
+ end
145
+ module_endpoint_pattern = /^(help\s+)?([A-Z]\w*)\s*\.\s*(\w*)$/
146
+ Bond::M.complete(:on => module_endpoint_pattern, :action => lambda { |input|
147
+ match = input.line.match(module_endpoint_pattern)
148
+ if match
149
+ is_help = !match[1].nil?
150
+ module_name = match[2]
151
+ method_prefix = match[3]
152
+ gem_name = module_to_gem[module_name]
153
+
154
+ if gem_name
155
+ help_data = Salad::GEM_HELP[gem_name.to_sym]
156
+ endpoints = help_data&.[](:endpoints)&.keys&.map(&:to_s) || []
157
+
158
+ if is_help
159
+ endpoints
160
+ .select { |e| e.start_with?(method_prefix) }
161
+ .sort
162
+ .map { |e| "#{module_name}.#{e}" }
163
+ else
164
+ public_methods = begin
165
+ Object.const_get(module_name).methods(false).map(&:to_s)
166
+ rescue NameError
167
+ []
168
+ end
169
+
170
+ (endpoints + public_methods).uniq
171
+ .select { |m| m.start_with?(method_prefix) }
172
+ .sort
173
+ .map { |m| "#{module_name}.#{m}" }
174
+ end
175
+ else
176
+ []
177
+ end
178
+ else
179
+ []
180
+ end
181
+ })
182
+
183
+ # Completion for keyword arguments inside a method call:
184
+ # "Salad.gem.method(" or "Salad.gem.method(prior: 1, partial"
185
+ # Shows only the params documented for that specific endpoint in GEM_HELP,
186
+ # and hides params already supplied in the current call.
187
+ # :search => false disables Bond's normal_search filter, which would otherwise
188
+ # require candidates to start with the full line (e.g. "Salad.inaturalist.observations(").
189
+ # We return short candidates ("taxon_id: ") so Readline replaces only the word
190
+ # after the "(" break-char instead of doubling the whole prefix.
191
+ salad_kwarg_pattern = /^Salad\.(\w+)\s*\.\s*(\w+)\s*\(([^)]*)$/
192
+ Bond::M.complete(:on => salad_kwarg_pattern, :search => false, :action => lambda { |input|
193
+ match = input.line.match(salad_kwarg_pattern)
194
+ if match
195
+ gem_name = match[1]
196
+ method_name = match[2]
197
+ inside_parens = match[3]
198
+
199
+ partial = inside_parens[/(\w*)\z/] || ''
200
+ prefix_in_parens = inside_parens[0...(inside_parens.length - partial.length)]
201
+
202
+ actual_gem_name = if Salad::SERVICES.key?(gem_name.to_sym)
203
+ Salad::SERVICES[gem_name.to_sym]
204
+ elsif Salad::GEMS.include?(gem_name)
205
+ gem_name
206
+ end
207
+
208
+ if actual_gem_name
209
+ help_data = Salad::GEM_HELP[actual_gem_name.to_sym]
210
+ endpoint = help_data && help_data[:endpoints] && help_data[:endpoints][method_name.to_sym]
211
+
212
+ if endpoint && endpoint[:params]
213
+ params = endpoint[:params].keys.map(&:to_s)
214
+ used = prefix_in_parens.scan(/(\w+)\s*:/).flatten
215
+ # Return only the param + ": " — Readline treats "(" as a word break,
216
+ # so the "word" being completed starts after the last "(" (or space),
217
+ # not at the beginning of the line. Returning a full-line candidate
218
+ # would cause Readline to insert it after the "(", doubling the prefix.
219
+ (params - used)
220
+ .select { |p| p.start_with?(partial) }
221
+ .sort
222
+ .map { |p| "#{p}: " }
223
+ else
224
+ []
225
+ end
226
+ else
227
+ []
228
+ end
229
+ else
230
+ []
231
+ end
232
+ })
233
+
234
+ # Same keyword-arg completion for raw module form: "Nasturtium.observations(...".
235
+ # Only documented params for the specific endpoint are returned, hiding ones
236
+ # already supplied in the current call.
237
+ module_kwarg_pattern = /^([A-Z]\w*)\s*\.\s*(\w+)\s*\(([^)]*)$/
238
+ Bond::M.complete(:on => module_kwarg_pattern, :search => false, :action => lambda { |input|
239
+ match = input.line.match(module_kwarg_pattern)
240
+ if match
241
+ module_name = match[1]
242
+ method_name = match[2]
243
+ inside_parens = match[3]
244
+ gem_name = module_to_gem[module_name]
245
+
246
+ if gem_name
247
+ partial = inside_parens[/(\w*)\z/] || ''
248
+ prefix_in_parens = inside_parens[0...(inside_parens.length - partial.length)]
249
+
250
+ help_data = Salad::GEM_HELP[gem_name.to_sym]
251
+ endpoint = help_data && help_data[:endpoints] && help_data[:endpoints][method_name.to_sym]
252
+
253
+ if endpoint && endpoint[:params]
254
+ params = endpoint[:params].keys.map(&:to_s)
255
+ used = prefix_in_parens.scan(/(\w+)\s*:/).flatten
256
+ (params - used)
257
+ .select { |p| p.start_with?(partial) }
258
+ .sort
259
+ .map { |p| "#{p}: " }
260
+ else
261
+ []
262
+ end
263
+ else
264
+ []
265
+ end
266
+ else
267
+ []
268
+ end
269
+ })
270
+
271
+ Pry.config.commands.create_command('help', 'Show Salad services and gem documentation') do
272
+ def process(*args)
273
+ topic = args.join(' ')
274
+ if args.empty? || topic == 'Salad'
275
+ show_salad_services_help
276
+ elsif !show_service_help(topic)
277
+ puts "\nNo Salad help available for `#{topic}`."
278
+ puts "Try `help` (no args) to list services, or Pry's `show-doc #{topic}` / `ls #{topic}` for object docs."
279
+ end
280
+ end
281
+
282
+ private
283
+
284
+ def show_salad_services_help
285
+ puts "\n=== Salad Services ==="
286
+ puts "Access bundled biodiversity informatics API wrappers by service name or gem name:\n"
287
+ service_width = Salad::SERVICES.keys.map { |s| s.to_s.length }.max
288
+ gem_width = Salad::SERVICES.values.map(&:length).max
289
+ Salad::SERVICES.sort.each do |service, gem_name|
290
+ mod = begin
291
+ Salad.gem_module(gem_name).to_s
292
+ rescue NameError
293
+ '(not loaded)'
294
+ end
295
+ printf " Salad.%-#{service_width}s Salad.%-#{gem_width}s -> %s\n", service, gem_name, mod
296
+ end
297
+ puts "\nUsage Examples:"
298
+ puts " help Salad.checkerberry # Show all endpoints (try: help Salad.check<TAB>)"
299
+ puts " help Salad.checkerberry.verify # Show specific endpoint (try: help Salad.checkerberry.v<TAB>)"
300
+ puts " help gnverifier # Also works with service or gem name"
301
+ puts "\nAPI Examples:"
302
+ puts " Salad.worms.record(127160)"
303
+ puts " Crawlyflower.record(127160) # Same as above"
304
+ puts " Salad.inaturalist.observations(id: 1234)"
305
+ puts "\nTab-completion is powered by Bond and shows only Salad endpoints.\n\n"
306
+ end
307
+
308
+ def show_service_help(topic)
309
+ begin
310
+ # Try to match endpoint pattern: "Salad.gem_name.endpoint_name"
311
+ if topic =~ /^Salad\.([\w_]+)\.([\w_]+)$/
312
+ gem_name = Regexp.last_match(1)
313
+ endpoint_name = Regexp.last_match(2)
314
+ return show_endpoint_help(gem_name, endpoint_name)
315
+ end
316
+
317
+ # Try to match Salad service pattern: "Salad.service_name" or "Salad.gem_name"
318
+ if topic =~ /^Salad\.([\w_]+)$/
319
+ name = Regexp.last_match(1)
320
+ # Check if it's a service name, and convert to gem name
321
+ gem_name = Salad::SERVICES[name.to_sym] || name
322
+ show_gem_methods(gem_name)
323
+ return true
324
+ end
325
+
326
+ # Try matching just service or gem name: "inaturalist" or "nasturtium"
327
+ if Salad::SERVICES.key?(topic.to_sym)
328
+ gem_name = Salad::SERVICES[topic.to_sym]
329
+ show_gem_methods(gem_name)
330
+ return true
331
+ elsif Salad::GEMS.include?(topic)
332
+ show_gem_methods(topic)
333
+ return true
334
+ end
335
+
336
+ # CamelCase module form: "Nasturtium" or "Nasturtium.endpoint"
337
+ if topic =~ /^([A-Z]\w*)(?:\.([\w_]+))?$/
338
+ module_name = Regexp.last_match(1)
339
+ endpoint_name = Regexp.last_match(2)
340
+ gem_name = Salad.gem_for_module(module_name)
341
+ if gem_name
342
+ return show_endpoint_help(gem_name, endpoint_name) if endpoint_name
343
+ show_gem_methods(gem_name)
344
+ return true
345
+ end
346
+ end
347
+
348
+ false
349
+ rescue => e
350
+ puts "\nError loading help: #{e.class}: #{e.message}"
351
+ false
352
+ end
353
+ end
354
+
355
+ def show_gem_methods(service_or_gem)
356
+ mod = begin
357
+ Salad.gem_module(service_or_gem)
358
+ rescue NameError
359
+ puts "Service/gem '#{service_or_gem}' not found."
360
+ return
361
+ end
362
+
363
+ puts "\n=== #{mod.to_s} ==="
364
+
365
+ # Check for detailed help documentation
366
+ gem_sym = service_or_gem.to_sym
367
+ if Salad::GEM_HELP[gem_sym]
368
+ show_detailed_gem_help(gem_sym, mod, service_or_gem)
369
+ else
370
+ show_generic_gem_help(mod, service_or_gem)
371
+ end
372
+ end
373
+
374
+ def show_endpoint_help(gem_name, endpoint_name)
375
+ # Convert service name to gem name if needed
376
+ actual_gem_name = if Salad::SERVICES.key?(gem_name.to_sym)
377
+ Salad::SERVICES[gem_name.to_sym]
378
+ else
379
+ gem_name
380
+ end
381
+ gem_sym = actual_gem_name.to_sym
382
+
383
+ # Check if gem has documented help
384
+ unless Salad::GEM_HELP[gem_sym]
385
+ puts "\nNo detailed documentation available for #{gem_name}.#{endpoint_name}"
386
+ return true
387
+ end
388
+
389
+ help = Salad::GEM_HELP[gem_sym]
390
+ endpoints = help[:endpoints]
391
+
392
+ unless endpoints
393
+ puts "\nNo endpoints documented for #{gem_name}"
394
+ return true
395
+ end
396
+
397
+ endpoint_sym = endpoint_name.to_sym
398
+ endpoint_info = endpoints[endpoint_sym]
399
+
400
+ unless endpoint_info
401
+ puts "\nEndpoint '#{endpoint_name}' not found in #{gem_name}."
402
+ puts "\nAvailable endpoints: #{endpoints.keys.join(', ')}"
403
+ return true
404
+ end
405
+
406
+ mod = begin
407
+ Salad.gem_module(actual_gem_name)
408
+ rescue NameError
409
+ nil
410
+ end
411
+
412
+ module_name = mod ? mod.to_s : actual_gem_name.camelize
413
+ puts "\n=== #{module_name}.#{endpoint_name} ==="
414
+ puts "\n#{endpoint_info[:description]}"
415
+
416
+ if endpoint_info[:params]
417
+ puts "\nParameters:"
418
+ endpoint_info[:params].each do |param_name, param_desc|
419
+ puts " #{param_name.to_s.ljust(20)} — #{param_desc}"
420
+ end
421
+ end
422
+
423
+ if endpoint_info[:examples]
424
+ puts "\nExamples:"
425
+ endpoint_info[:examples].each { |ex| puts " #{ex}" }
426
+ end
427
+
428
+ puts "\n"
429
+ true
430
+ end
431
+
432
+ def show_detailed_gem_help(gem_sym, mod, gem_name)
433
+ help = Salad::GEM_HELP[gem_sym]
434
+
435
+ puts "\n#{help[:description]}"
436
+ puts "API: #{help[:api]}"
437
+
438
+ if help[:endpoints]
439
+ puts "\n--- Available Endpoints ---"
440
+ help[:endpoints].each do |endpoint_name, endpoint_info|
441
+ begin
442
+ puts "\n#{endpoint_name}"
443
+ puts " #{endpoint_info[:description]}"
444
+
445
+ if endpoint_info[:examples]
446
+ puts " Example: #{endpoint_info[:examples].first}"
447
+ end
448
+
449
+ puts " ↳ help Salad.#{gem_name}.#{endpoint_name} # Full documentation"
450
+ rescue => e
451
+ puts "\n#{endpoint_name}"
452
+ puts " (Documentation temporarily unavailable)"
453
+ end
454
+ end
455
+ end
456
+
457
+ puts "\n"
458
+ end
459
+
460
+ def show_generic_gem_help(mod, service_or_gem)
461
+ # Show module methods
462
+ public_methods = mod.methods(false).sort
463
+ if public_methods.any?
464
+ puts "\nPublic methods:"
465
+ public_methods.each { |m| puts " #{m}" }
466
+ end
467
+
468
+ # Show constants (often contain useful classes)
469
+ constants = mod.constants(false).sort
470
+ if constants.any?
471
+ puts "\nAvailable classes/constants:"
472
+ module_name = mod.to_s
473
+ constants.each { |c| puts " #{module_name}::#{c}" }
474
+ end
475
+
476
+ puts "\nUsage examples:"
477
+ gem_name = service_or_gem.include?('_') ? service_or_gem :
478
+ (Salad::SERVICES.find { |_, v| v == service_or_gem }&.last || service_or_gem)
479
+ module_name = mod.to_s
480
+ if mod.respond_to?(:help)
481
+ puts " #{module_name}.help # Gem documentation"
482
+ end
483
+ puts " Salad.#{gem_name}.observations(...) # Call available methods"
484
+ puts " #{module_name}.observations(...) # Or use the gem name directly"
485
+ puts "\n\n"
486
+ end
487
+ end
488
+
489
+ Pry.config.commands.create_command('hints', 'Show tips for using the Salad console') do
490
+ def process
491
+ puts "\n=== Salad Tips ==="
492
+ Salad::HINTS.each_with_index do |hint, i|
493
+ puts " #{(i + 1).to_s.rjust(2)}. #{hint}"
494
+ end
495
+ puts "\n"
496
+ end
497
+ end
498
+
499
+ puts "salad v#{Salad::VERSION} — type `help` to list services, `hints` for tips."
500
+ puts "Tip: #{Salad::HINTS.sample}"
501
+ Pry.start
502
+ end
503
+ end