docscribe 1.4.1 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +149 -0
  3. data/lib/docscribe/cli/config_builder.rb +125 -35
  4. data/lib/docscribe/cli/generate.rb +288 -117
  5. data/lib/docscribe/cli/init.rb +49 -13
  6. data/lib/docscribe/cli/options.rb +302 -127
  7. data/lib/docscribe/cli/run.rb +391 -135
  8. data/lib/docscribe/cli.rb +23 -5
  9. data/lib/docscribe/config/defaults.rb +11 -11
  10. data/lib/docscribe/config/emit.rb +1 -0
  11. data/lib/docscribe/config/filtering.rb +24 -11
  12. data/lib/docscribe/config/loader.rb +7 -4
  13. data/lib/docscribe/config/plugin.rb +1 -0
  14. data/lib/docscribe/config/rbs.rb +31 -22
  15. data/lib/docscribe/config/sorbet.rb +41 -15
  16. data/lib/docscribe/config/sorting.rb +1 -0
  17. data/lib/docscribe/config/template.rb +1 -0
  18. data/lib/docscribe/config/utils.rb +1 -0
  19. data/lib/docscribe/config.rb +1 -0
  20. data/lib/docscribe/infer/constants.rb +15 -0
  21. data/lib/docscribe/infer/literals.rb +43 -25
  22. data/lib/docscribe/infer/names.rb +24 -15
  23. data/lib/docscribe/infer/params.rb +52 -6
  24. data/lib/docscribe/infer/raises.rb +24 -14
  25. data/lib/docscribe/infer/returns.rb +365 -182
  26. data/lib/docscribe/infer.rb +10 -9
  27. data/lib/docscribe/inline_rewriter/collector.rb +766 -375
  28. data/lib/docscribe/inline_rewriter/doc_block.rb +217 -74
  29. data/lib/docscribe/inline_rewriter/doc_builder.rb +1488 -602
  30. data/lib/docscribe/inline_rewriter/source_helpers.rb +100 -52
  31. data/lib/docscribe/inline_rewriter/tag_sorter.rb +109 -48
  32. data/lib/docscribe/inline_rewriter.rb +1009 -595
  33. data/lib/docscribe/plugin/base/collector_plugin.rb +2 -3
  34. data/lib/docscribe/plugin/base/tag_plugin.rb +1 -1
  35. data/lib/docscribe/plugin/registry.rb +34 -7
  36. data/lib/docscribe/plugin.rb +48 -17
  37. data/lib/docscribe/types/rbs/collection_loader.rb +0 -1
  38. data/lib/docscribe/types/rbs/provider.rb +75 -26
  39. data/lib/docscribe/types/rbs/type_formatter.rb +127 -59
  40. data/lib/docscribe/types/sorbet/base_provider.rb +31 -12
  41. data/lib/docscribe/version.rb +1 -1
  42. metadata +2 -2
@@ -14,6 +14,23 @@ module Docscribe
14
14
  module Generate
15
15
  PLUGIN_TYPES = %w[tag collector].freeze
16
16
 
17
+ NEXT_STEPS_TEMPLATE = <<~TEXT
18
+ Next steps:
19
+ 1. Open %<path>s and implement the plugin logic.
20
+ %<hint>s
21
+
22
+ 2. Register the plugin in your docscribe_plugins.rb:
23
+
24
+ require_relative '%<require_path>s'
25
+ Docscribe::Plugin::Registry.register(%<base_name>s.new)
26
+
27
+ 3. Add the file to docscribe.yml:
28
+
29
+ plugins:
30
+ require:
31
+ - ./docscribe_plugins
32
+ TEXT
33
+
17
34
  class << self
18
35
  # Run the `generate` subcommand.
19
36
  #
@@ -21,76 +38,61 @@ module Docscribe
21
38
  # @raise [OptionParser::InvalidOption]
22
39
  # @return [Integer] exit code
23
40
  def run(argv)
24
- opts = {
25
- output: nil,
26
- stdout: false
27
- }
28
-
29
- parser = OptionParser.new do |o|
30
- o.banner = <<~TEXT
31
- Usage: docscribe generate <type> <PluginName> [options]
32
-
33
- Types:
34
- tag Generate a TagPlugin skeleton
35
- collector Generate a CollectorPlugin skeleton
36
-
37
- Options:
38
- TEXT
39
-
40
- o.on('--output DIR', 'Directory to write the plugin file (default: .)') { |v| opts[:output] = v }
41
- o.on('--stdout', 'Print the generated plugin to STDOUT instead of writing a file') { opts[:stdout] = true }
42
- o.on('-h', '--help', 'Show this help') do
43
- puts o
44
- return 0
45
- end
46
- end
41
+ opts, parser = parse_generate_options(argv)
42
+ return 0 if opts[:help]
43
+
44
+ plugin_type, class_name = extract_generate_args(argv)
45
+ result = validate_generate_args(plugin_type, class_name, parser)
46
+ return result if result
47
+
48
+ content = render(plugin_type, class_name)
49
+ dispatch_output(content, plugin_type, class_name, opts)
50
+ end
51
+
52
+ private
53
+
54
+ # Parse options for the generate subcommand.
55
+ #
56
+ # @private
57
+ # @param [Array<String>] argv
58
+ # @raise [OptionParser::InvalidOption]
59
+ # @return [Array(Hash, OptionParser)]
60
+ def parse_generate_options(argv)
61
+ opts = { output: nil, stdout: false, help: false }
62
+ parser = build_option_parser(opts)
47
63
 
48
64
  begin
49
65
  parser.parse!(argv)
50
66
  rescue OptionParser::InvalidOption => e
51
67
  warn e.message
52
68
  warn parser
53
- return 1
54
- end
55
-
56
- plugin_type = argv.shift
57
- class_name = argv.shift
58
-
59
- unless plugin_type && class_name
60
- warn 'Error: both <type> and <PluginName> are required.'
61
- warn parser
62
- return 1
63
- end
64
-
65
- unless PLUGIN_TYPES.include?(plugin_type)
66
- warn "Error: unknown type #{plugin_type.inspect}. Must be one of: #{PLUGIN_TYPES.join(', ')}."
67
- return 1
68
- end
69
-
70
- unless valid_constant?(class_name)
71
- warn "Error: #{class_name.inspect} is not a valid Ruby constant name."
72
- return 1
73
69
  end
74
70
 
75
- content = render(plugin_type, class_name)
76
-
77
- if opts[:stdout]
78
- puts content
79
- return 0
80
- end
81
-
82
- write_plugin(content, plugin_type: plugin_type, class_name: class_name, output_dir: opts[:output] || '.')
71
+ [opts, parser]
83
72
  end
84
73
 
85
- private
74
+ # Extract plugin_type and class_name from remaining argv.
75
+ #
76
+ # @private
77
+ # @param [Array<String>] argv
78
+ # @return [Array(String, String)]
79
+ def extract_generate_args(argv)
80
+ [argv.shift, argv.shift]
81
+ end
86
82
 
87
- # Check whether a string is a valid Ruby constant name.
83
+ # Validate generate arguments and return exit code on failure.
88
84
  #
89
85
  # @private
90
- # @param [String] str
91
- # @return [Boolean]
92
- def valid_constant?(str)
93
- !!(str =~ /\A[A-Z][A-Za-z0-9]*(?:::[A-Z][A-Za-z0-9]*)*\z/)
86
+ # @param [String, nil] plugin_type
87
+ # @param [String, nil] class_name
88
+ # @param [OptionParser] parser
89
+ # @return [Integer, nil] exit code or nil if valid
90
+ def validate_generate_args(plugin_type, class_name, parser)
91
+ return 1 unless args_provided?(plugin_type, class_name, parser)
92
+ return 1 unless known_type?(plugin_type)
93
+ return 1 unless valid_name?(class_name)
94
+
95
+ nil
94
96
  end
95
97
 
96
98
  # Render plugin boilerplate for the given type and class name.
@@ -106,66 +108,6 @@ module Docscribe
106
108
  end
107
109
  end
108
110
 
109
- # Write the generated content to a file.
110
- #
111
- # @private
112
- # @param [String] content
113
- # @param [String] plugin_type
114
- # @param [String] class_name
115
- # @param [String] output_dir
116
- # @return [Integer] exit code
117
- def write_plugin(content, plugin_type:, class_name:, output_dir:)
118
- filename = "#{underscore(class_name)}.rb"
119
- path = File.join(output_dir, filename)
120
-
121
- if File.exist?(path)
122
- warn "Error: #{path} already exists. Remove it first or use --stdout."
123
- return 1
124
- end
125
-
126
- require 'fileutils'
127
- FileUtils.mkdir_p(output_dir)
128
- File.write(path, content)
129
- puts "Created: #{path}"
130
- puts
131
- puts next_steps(plugin_type, path)
132
- 0
133
- end
134
-
135
- # Print registration instructions after file creation.
136
- #
137
- # @private
138
- # @param [String] plugin_type
139
- # @param [String] path
140
- # @return [String]
141
- def next_steps(plugin_type, path)
142
- base_name = File.basename(path, '.rb').split('_').map(&:capitalize).join
143
-
144
- implement_hint = case plugin_type
145
- when 'tag'
146
- 'Implement the #call method to return Array<Docscribe::Plugin::Tag>.'
147
- when 'collector'
148
- 'Implement the #collect method to return Array<{anchor_node:, doc:}>.'
149
- end
150
-
151
- <<~TEXT
152
- Next steps:
153
- 1. Open #{path} and implement the plugin logic.
154
- #{implement_hint}
155
-
156
- 2. Register the plugin in your docscribe_plugins.rb:
157
-
158
- require_relative '#{path.delete_suffix('.rb')}'
159
- Docscribe::Plugin::Registry.register(#{base_name}.new)
160
-
161
- 3. Add the file to docscribe.yml:
162
-
163
- plugins:
164
- require:
165
- - ./docscribe_plugins
166
- TEXT
167
- end
168
-
169
111
  # Template for a TagPlugin.
170
112
  #
171
113
  # @private
@@ -292,6 +234,161 @@ module Docscribe
292
234
  RUBY
293
235
  end
294
236
 
237
+ # Write generated plugin to a file or print to STDOUT based on options.
238
+ #
239
+ # @private
240
+ # @param [String] content generated plugin source code
241
+ # @param [String] plugin_type 'tag' or 'collector'
242
+ # @param [String] class_name CamelCase plugin class name
243
+ # @param [Hash] opts parsed options hash
244
+ # @return [Integer] exit code
245
+ def dispatch_output(content, plugin_type, class_name, opts)
246
+ if opts[:stdout]
247
+ puts content
248
+ return 0
249
+ end
250
+
251
+ write_plugin(content, plugin_type: plugin_type, class_name: class_name, output_dir: opts[:output] || '.')
252
+ end
253
+
254
+ # Write the generated content to a file.
255
+ #
256
+ # @private
257
+ # @param [String] content
258
+ # @param [String] plugin_type
259
+ # @param [String] class_name
260
+ # @param [String] output_dir
261
+ # @return [Integer] exit code
262
+ def write_plugin(content, plugin_type:, class_name:, output_dir:)
263
+ path = plugin_path(class_name, output_dir)
264
+
265
+ return 1 if file_exists?(path)
266
+
267
+ write_to_file(output_dir, path, content)
268
+ print_created(plugin_type, path)
269
+ 0
270
+ end
271
+
272
+ # Build the OptionParser for the generate subcommand.
273
+ #
274
+ # @private
275
+ # @param [Hash] opts mutable parsed options hash
276
+ # @return [OptionParser]
277
+ def build_option_parser(opts)
278
+ OptionParser.new do |opt|
279
+ opt.banner = parser_banner
280
+ register_output_option(opt, opts)
281
+ register_stdout_option(opt, opts)
282
+ register_help_option(opt, opts)
283
+ end
284
+ end
285
+
286
+ # Return the usage banner for the generate subcommand parser.
287
+ #
288
+ # @private
289
+ # @return [String]
290
+ def parser_banner
291
+ <<~TEXT
292
+ Usage: docscribe generate <type> <PluginName> [options]
293
+
294
+ Types:
295
+ tag Generate a TagPlugin skeleton
296
+ collector Generate a CollectorPlugin skeleton
297
+
298
+ Options:
299
+ TEXT
300
+ end
301
+
302
+ # Register the --output option on the OptionParser.
303
+ #
304
+ # @private
305
+ # @param [OptionParser] opt
306
+ # @param [Hash] opts mutable parsed options hash
307
+ # @return [void]
308
+ def register_output_option(opt, opts)
309
+ opt.on('--output DIR', 'Directory to write the plugin file (default: .)') { |v| opts[:output] = v }
310
+ end
311
+
312
+ # Register the --stdout option on the OptionParser.
313
+ #
314
+ # @private
315
+ # @param [OptionParser] opt
316
+ # @param [Hash] opts mutable parsed options hash
317
+ # @return [void]
318
+ def register_stdout_option(opt, opts)
319
+ opt.on('--stdout', 'Print the generated plugin to STDOUT instead of writing a file') { opts[:stdout] = true }
320
+ end
321
+
322
+ # Register the -h/--help option on the OptionParser.
323
+ #
324
+ # @private
325
+ # @param [OptionParser] opt
326
+ # @param [Hash] opts mutable parsed options hash
327
+ # @return [void]
328
+ def register_help_option(opt, opts)
329
+ opt.on('-h', '--help', 'Show this help') do
330
+ opts[:help] = true
331
+ end
332
+ end
333
+
334
+ # Validate that both plugin_type and class_name arguments were provided.
335
+ #
336
+ # @private
337
+ # @param [String, nil] plugin_type plugin type argument
338
+ # @param [String, nil] class_name plugin class name argument
339
+ # @param [OptionParser] parser
340
+ # @return [Boolean]
341
+ def args_provided?(plugin_type, class_name, parser)
342
+ return true if plugin_type && class_name
343
+
344
+ warn 'Error: both <type> and <PluginName> are required.'
345
+ warn parser
346
+ false
347
+ end
348
+
349
+ # Validate that the plugin type is one of the recognized types.
350
+ #
351
+ # @private
352
+ # @param [String] plugin_type plugin type to validate
353
+ # @return [Boolean]
354
+ def known_type?(plugin_type)
355
+ return true if PLUGIN_TYPES.include?(plugin_type)
356
+
357
+ warn "Error: unknown type #{plugin_type.inspect}. Must be one of: #{PLUGIN_TYPES.join(', ')}."
358
+ false
359
+ end
360
+
361
+ # Validate that the class name is a valid Ruby constant name.
362
+ #
363
+ # @private
364
+ # @param [String] class_name class name to validate
365
+ # @return [Boolean]
366
+ def valid_name?(class_name)
367
+ return true if valid_constant?(class_name)
368
+
369
+ warn "Error: #{class_name.inspect} is not a valid Ruby constant name."
370
+ false
371
+ end
372
+
373
+ # Check whether a string is a valid Ruby constant name.
374
+ #
375
+ # @private
376
+ # @param [String] str
377
+ # @return [Boolean]
378
+ def valid_constant?(str)
379
+ !!(str =~ /\A[A-Z][A-Za-z0-9]*(?:::[A-Z][A-Za-z0-9]*)*\z/)
380
+ end
381
+
382
+ # Build the file path for the generated plugin.
383
+ #
384
+ # @private
385
+ # @param [String] class_name CamelCase plugin class name
386
+ # @param [String] output_dir output directory
387
+ # @return [String] full file path
388
+ def plugin_path(class_name, output_dir)
389
+ File.join(output_dir || '.', "#{underscore(class_name || '')}.rb")
390
+ end
391
+
295
392
  # Convert CamelCase to snake_case for file naming.
296
393
  #
297
394
  # @private
@@ -303,6 +400,80 @@ module Docscribe
303
400
  .gsub(/([a-z\d])([A-Z])/, '\1_\2')
304
401
  .downcase
305
402
  end
403
+
404
+ # Check whether the target plugin file already exists and warn if so.
405
+ #
406
+ # @private
407
+ # @param [String] path file path to check
408
+ # @return [Boolean]
409
+ def file_exists?(path)
410
+ return false unless File.exist?(path)
411
+
412
+ warn "Error: #{path} already exists. Remove it first or use --stdout."
413
+ true
414
+ end
415
+
416
+ # Create the output directory and write the plugin file.
417
+ #
418
+ # @private
419
+ # @param [String] output_dir output directory path
420
+ # @param [String] path full plugin file path
421
+ # @param [String] content file content to write
422
+ # @return [void]
423
+ def write_to_file(output_dir, path, content)
424
+ require 'fileutils'
425
+ FileUtils.mkdir_p(output_dir || '.')
426
+ File.write(path, content)
427
+ end
428
+
429
+ # Print the creation message and next steps after generating a plugin.
430
+ #
431
+ # @private
432
+ # @param [String] plugin_type 'tag' or 'collector'
433
+ # @param [String] path file path of the created plugin
434
+ # @return [void]
435
+ def print_created(plugin_type, path)
436
+ puts "Created: #{path}"
437
+ puts
438
+ puts next_steps(plugin_type, path)
439
+ end
440
+
441
+ # Print registration instructions after file creation.
442
+ #
443
+ # @private
444
+ # @param [String] plugin_type
445
+ # @param [String] path
446
+ # @return [String]
447
+ def next_steps(plugin_type, path)
448
+ format(NEXT_STEPS_TEMPLATE,
449
+ path: path,
450
+ hint: generate_implement_hint(plugin_type),
451
+ require_path: path.delete_suffix('.rb'),
452
+ base_name: plugin_base_name(path))
453
+ end
454
+
455
+ # Derive a CamelCase base name from a snake_case file path.
456
+ #
457
+ # @private
458
+ # @param [String] path file path
459
+ # @return [String] CamelCase class name
460
+ def plugin_base_name(path)
461
+ File.basename(path, '.rb').split('_').map(&:capitalize).join
462
+ end
463
+
464
+ # Generate an implementation hint string for the given plugin type.
465
+ #
466
+ # @private
467
+ # @param [String] plugin_type 'tag' or 'collector'
468
+ # @return [String] hint text
469
+ def generate_implement_hint(plugin_type)
470
+ case plugin_type
471
+ when 'tag'
472
+ 'Implement the #call method to return Array<Docscribe::Plugin::Tag>.'
473
+ when 'collector'
474
+ 'Implement the #collect method to return Array<{anchor_node:, doc:}>.'
475
+ end
476
+ end
306
477
  end
307
478
  end
308
479
  end
@@ -5,6 +5,7 @@ require 'docscribe/config'
5
5
 
6
6
  module Docscribe
7
7
  module CLI
8
+ # Generate starter Docscribe configuration.
8
9
  module Init
9
10
  class << self
10
11
  # Create or print a starter Docscribe configuration file.
@@ -18,30 +19,65 @@ module Docscribe
18
19
  # @param [Array<String>] argv command-line arguments for `docscribe init`
19
20
  # @return [Integer] process exit code
20
21
  def run(argv)
21
- opts = {
22
- config: 'docscribe.yml',
23
- force: false,
24
- stdout: false
25
- }
22
+ opts = parse_init_options(argv)
23
+ return 0 if opts[:help]
26
24
 
25
+ yaml = Docscribe::Config.default_yaml
26
+
27
+ if opts[:stdout]
28
+ puts yaml
29
+ return 0
30
+ end
31
+
32
+ write_init_config(opts, yaml)
33
+ end
34
+
35
+ private
36
+
37
+ # Parse CLI options for `docscribe init`.
38
+ #
39
+ # @private
40
+ # @param [Array<String>] argv
41
+ # @return [Hash] parsed options
42
+ def parse_init_options(argv)
43
+ opts = default_init_options
44
+ build_init_parser(opts).parse!(argv)
45
+ opts
46
+ end
47
+
48
+ # Return the default options hash for the init command.
49
+ #
50
+ # @private
51
+ # @return [Hash]
52
+ def default_init_options
53
+ { config: 'docscribe.yml', force: false, stdout: false, help: false }
54
+ end
55
+
56
+ # Build and return an OptionParser for the init command.
57
+ #
58
+ # @private
59
+ # @param [Hash] opts options hash that the parser populates
60
+ # @return [OptionParser]
61
+ def build_init_parser(opts)
27
62
  OptionParser.new do |o|
28
63
  o.banner = 'Usage: docscribe init [options]'
29
64
  o.on('--config PATH', 'Where to write the config (default: docscribe.yml)') { |v| opts[:config] = v }
30
65
  o.on('-f', '--force', 'Overwrite if the file already exists') { opts[:force] = true }
31
66
  o.on('--stdout', 'Print config template to STDOUT instead of writing a file') { opts[:stdout] = true }
32
67
  o.on('-h', '--help', 'Show this help') do
68
+ opts[:help] = true
33
69
  puts o
34
- return 0
35
70
  end
36
- end.parse!(argv)
37
-
38
- yaml = Docscribe::Config.default_yaml
39
-
40
- if opts[:stdout]
41
- puts yaml
42
- return 0
43
71
  end
72
+ end
44
73
 
74
+ # Write the config template to a file.
75
+ #
76
+ # @private
77
+ # @param [Hash] opts parsed options
78
+ # @param [String] yaml config template content
79
+ # @return [Integer] exit code
80
+ def write_init_config(opts, yaml)
45
81
  path = opts[:config]
46
82
  if File.exist?(path) && !opts[:force]
47
83
  warn "Config already exists: #{path} (use --force to overwrite)"