docscribe 1.4.2 → 1.5.0

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +465 -130
  3. data/lib/docscribe/cli/check_for_comments.rb +183 -0
  4. data/lib/docscribe/cli/config_builder.rb +107 -53
  5. data/lib/docscribe/cli/formatters/json.rb +294 -0
  6. data/lib/docscribe/cli/formatters/sarif.rb +235 -0
  7. data/lib/docscribe/cli/formatters/text.rb +208 -0
  8. data/lib/docscribe/cli/formatters.rb +26 -0
  9. data/lib/docscribe/cli/generate.rb +45 -45
  10. data/lib/docscribe/cli/init.rb +14 -6
  11. data/lib/docscribe/cli/options.rb +190 -88
  12. data/lib/docscribe/cli/rbs_gen.rb +529 -0
  13. data/lib/docscribe/cli/run.rb +210 -152
  14. data/lib/docscribe/cli/sigs.rb +366 -0
  15. data/lib/docscribe/cli/update_types.rb +103 -0
  16. data/lib/docscribe/cli.rb +21 -13
  17. data/lib/docscribe/config/defaults.rb +5 -1
  18. data/lib/docscribe/config/emit.rb +17 -0
  19. data/lib/docscribe/config/filtering.rb +18 -25
  20. data/lib/docscribe/config/loader.rb +15 -11
  21. data/lib/docscribe/config/plugin.rb +1 -1
  22. data/lib/docscribe/config/rbs.rb +41 -9
  23. data/lib/docscribe/config/sorbet.rb +9 -12
  24. data/lib/docscribe/config/sorting.rb +1 -1
  25. data/lib/docscribe/config/template.rb +9 -1
  26. data/lib/docscribe/config/utils.rb +11 -9
  27. data/lib/docscribe/config.rb +2 -4
  28. data/lib/docscribe/infer/ast_walk.rb +1 -1
  29. data/lib/docscribe/infer/literals.rb +6 -11
  30. data/lib/docscribe/infer/names.rb +2 -3
  31. data/lib/docscribe/infer/params.rb +15 -17
  32. data/lib/docscribe/infer/raises.rb +3 -5
  33. data/lib/docscribe/infer/returns.rb +542 -140
  34. data/lib/docscribe/infer.rb +22 -23
  35. data/lib/docscribe/inline_rewriter/collector.rb +159 -164
  36. data/lib/docscribe/inline_rewriter/doc_block.rb +145 -115
  37. data/lib/docscribe/inline_rewriter/doc_builder.rb +1026 -723
  38. data/lib/docscribe/inline_rewriter/source_helpers.rb +49 -49
  39. data/lib/docscribe/inline_rewriter/tag_sorter.rb +82 -85
  40. data/lib/docscribe/inline_rewriter.rb +495 -492
  41. data/lib/docscribe/parsing.rb +29 -10
  42. data/lib/docscribe/plugin/base/collector_plugin.rb +2 -1
  43. data/lib/docscribe/plugin/base/tag_plugin.rb +0 -1
  44. data/lib/docscribe/plugin/context.rb +28 -18
  45. data/lib/docscribe/plugin/registry.rb +26 -27
  46. data/lib/docscribe/plugin/tag.rb +9 -14
  47. data/lib/docscribe/plugin.rb +17 -16
  48. data/lib/docscribe/types/provider_chain.rb +4 -2
  49. data/lib/docscribe/types/rbs/collection_loader.rb +2 -2
  50. data/lib/docscribe/types/rbs/provider.rb +60 -44
  51. data/lib/docscribe/types/rbs/type_formatter.rb +224 -83
  52. data/lib/docscribe/types/signature.rb +22 -42
  53. data/lib/docscribe/types/sorbet/base_provider.rb +24 -19
  54. data/lib/docscribe/types/sorbet/rbi_provider.rb +3 -3
  55. data/lib/docscribe/types/sorbet/source_provider.rb +3 -2
  56. data/lib/docscribe/types/yard/formatter.rb +100 -0
  57. data/lib/docscribe/types/yard/parser.rb +240 -0
  58. data/lib/docscribe/types/yard/types.rb +52 -0
  59. data/lib/docscribe/version.rb +1 -1
  60. metadata +33 -1
@@ -0,0 +1,529 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require 'optparse'
5
+ require 'fileutils'
6
+ require 'docscribe/parsing'
7
+ require 'docscribe/types/yard/parser'
8
+ require 'docscribe/types/yard/formatter'
9
+
10
+ module Docscribe
11
+ module CLI
12
+ # CLI subcommand to generate RBS files from YARD documentation
13
+ module RbsGen
14
+ BANNER = <<~TEXT
15
+ Usage: docscribe rbs [options] [files...]
16
+
17
+ Generate RBS signature files from YARD documentation.
18
+
19
+ TEXT
20
+
21
+ # @!attribute [rw] params
22
+ # @return [Array<Docscribe::CLI::RbsGen::ParamTag>]
23
+ # @param [Array<Docscribe::CLI::RbsGen::ParamTag>] value
24
+ #
25
+ # @!attribute [rw] return_type
26
+ # @return [String?]
27
+ # @param [String?] value
28
+ #
29
+ # @!attribute [rw] options
30
+ # @return [Array<Docscribe::CLI::RbsGen::ParamTag>]
31
+ # @param [Array<Docscribe::CLI::RbsGen::ParamTag>] value
32
+ YardTags = Struct.new(:params, :return_type, :options, keyword_init: true)
33
+ # @!attribute [rw] name
34
+ # @return [String]
35
+ # @param [String] value
36
+ #
37
+ # @!attribute [rw] type
38
+ # @return [String]
39
+ # @param [String] value
40
+ ParamTag = Struct.new(:name, :type, keyword_init: true)
41
+ # @!attribute [rw] name
42
+ # @return [Symbol]
43
+ # @param [Symbol] value
44
+ #
45
+ # @!attribute [rw] scope
46
+ # @return [Symbol]
47
+ # @param [Symbol] value
48
+ #
49
+ # @!attribute [rw] container
50
+ # @return [String?]
51
+ # @param [String?] value
52
+ #
53
+ # @!attribute [rw] file
54
+ # @return [String]
55
+ # @param [String] value
56
+ #
57
+ # @!attribute [rw] line
58
+ # @return [Integer]
59
+ # @param [Integer] value
60
+ #
61
+ # @!attribute [rw] yard_tags
62
+ # @return [Docscribe::CLI::RbsGen::YardTags?]
63
+ # @param [Docscribe::CLI::RbsGen::YardTags?] value
64
+ MethodDef = Struct.new(:name, :scope, :container, :file, :line, :yard_tags, keyword_init: true)
65
+ # @!attribute [rw] containers
66
+ # @return [Array<String>]
67
+ # @param [Array<String>] value
68
+ #
69
+ # @!attribute [rw] method_defs
70
+ # @return [Array<Docscribe::CLI::RbsGen::MethodDef>]
71
+ # @param [Array<Docscribe::CLI::RbsGen::MethodDef>] value
72
+ #
73
+ # @!attribute [rw] path
74
+ # @return [String]
75
+ # @param [String] value
76
+ #
77
+ # @!attribute [rw] comment_map
78
+ # @return [Hash<Integer, String>]
79
+ # @param [Hash<Integer, String>] value
80
+ #
81
+ # @!attribute [rw] src_lines
82
+ # @return [Array<String>]
83
+ # @param [Array<String>] value
84
+ #
85
+ # @!attribute [rw] inside_sclass
86
+ # @return [Boolean]
87
+ # @param [Boolean] value
88
+ WalkContext = Struct.new(:containers, :method_defs, :path, :comment_map, :src_lines, :inside_sclass, keyword_init: true)
89
+
90
+ class << self
91
+ # @param [Array<String>] argv
92
+ # @return [Integer]
93
+ def run(argv)
94
+ options = parse_options(argv)
95
+ paths = expand_paths(argv)
96
+ return no_files_found if paths.empty?
97
+
98
+ run_with(options, paths)
99
+ end
100
+
101
+ private
102
+
103
+ # @private
104
+ # @param [Array<String>] argv
105
+ # @return [Hash<Symbol, Object>]
106
+ def parse_options(argv)
107
+ options = { output_dir: 'sig', dry_run: false, force: false }
108
+ OptionParser.new(BANNER) do |opts|
109
+ opts.on('-o', '--output DIR', 'Output directory (default: sig)') { |d| options[:output_dir] = d }
110
+ opts.on('-n', '--dry-run', 'Print generated RBS to stdout') { options[:dry_run] = true }
111
+ opts.on('-f', '--force', 'Overwrite existing files') { options[:force] = true }
112
+ opts.on('-h', '--help', 'Show this help') { puts opts or exit 0 }
113
+ end.parse!(argv)
114
+ options
115
+ end
116
+
117
+ # @private
118
+ # @param [Array<String>] args
119
+ # @return [Array<String>]
120
+ def expand_paths(args)
121
+ files = [] #: Array[String]
122
+ args = ['.'] if args.empty?
123
+ args.each { |path| expand_single_path(files, path) }
124
+ files.uniq.sort
125
+ end
126
+
127
+ # @private
128
+ # @param [Array<String>] files
129
+ # @param [String] path
130
+ # @return [void]
131
+ def expand_single_path(files, path)
132
+ if File.directory?(path)
133
+ files.concat(Dir.glob(File.join(path, '**', '*.rb')))
134
+ elsif File.file?(path)
135
+ files << path
136
+ else
137
+ warn "Skipping missing path: #{path}"
138
+ end
139
+ end
140
+
141
+ # @private
142
+ # @return [Integer]
143
+ def no_files_found
144
+ warn 'No files found. Pass files or directories (e.g. `docscribe rbs lib`).'
145
+ 2
146
+ end
147
+
148
+ # @private
149
+ # @param [Hash<Symbol, Object>] options
150
+ # @param [Array<String>] paths
151
+ # @return [Integer]
152
+ def run_with(options, paths)
153
+ errors = 0
154
+ paths.each do |path|
155
+ generate_for_file(path, options) or (errors += 1)
156
+ end
157
+ errors.zero? ? 0 : 1
158
+ end
159
+
160
+ # @private
161
+ # @param [String] path
162
+ # @param [Hash<Symbol, Object>] options
163
+ # @raise [Parser::SyntaxError]
164
+ # @raise [StandardError]
165
+ # @return [Boolean] if StandardError
166
+ # @return [Boolean] if Parser::SyntaxError
167
+ # @return [Boolean] if StandardError
168
+ def generate_for_file(path, options)
169
+ process_source?(File.read(path), path, options)
170
+ rescue Parser::SyntaxError => e # steep:ignore
171
+ warn "Syntax error in #{path}: #{e.message}"
172
+ false
173
+ rescue StandardError => e
174
+ warn "Error processing #{path}: #{e.class}: #{e.message}"
175
+ false
176
+ end
177
+
178
+ # @private
179
+ # @param [String] src
180
+ # @param [String] path
181
+ # @param [Hash<Symbol, Object>] options
182
+ # @return [Boolean]
183
+ def process_source?(src, path, options)
184
+ src_lines = src.lines
185
+ res = Docscribe::Parsing.parse_with_comments(src, file: path)
186
+ return false unless res
187
+
188
+ ast = res[0]
189
+ method_defs = ast ? walk_source(ast, res[1], path, src_lines) : [] #: Array[MethodDef]
190
+ return true if method_defs.empty?
191
+
192
+ content = build_rbs_content(method_defs)
193
+ return false unless content
194
+
195
+ output_rbs(content, path, options)
196
+ true
197
+ end
198
+
199
+ # @private
200
+ # @param [Parser::AST::Node] ast
201
+ # @param [Array<Parser::Source::Comment>?] comments
202
+ # @param [String] path
203
+ # @param [Array<String>] src_lines
204
+ # @return [Array<Docscribe::CLI::RbsGen::MethodDef>]
205
+ def walk_source(ast, comments, path, src_lines)
206
+ comment_map = build_comment_map(comments)
207
+ containers = [] #: Array[String]
208
+ mdefs = [] #: Array[MethodDef]
209
+ ctx = WalkContext.new(containers: containers, method_defs: mdefs, path: path, comment_map: comment_map,
210
+ src_lines: src_lines, inside_sclass: false)
211
+ walk_for_methods(ast, ctx)
212
+ ctx.method_defs
213
+ end
214
+
215
+ # @private
216
+ # @param [String] content
217
+ # @param [String] path
218
+ # @param [Hash<Symbol, Object>] options
219
+ # @return [void]
220
+ def output_rbs(content, path, options)
221
+ if options[:dry_run]
222
+ puts content
223
+ else
224
+ write_file(content, path, options)
225
+ end
226
+ end
227
+
228
+ # @private
229
+ # @param [Array<Parser::Source::Comment>?] comments
230
+ # @return [Hash<Integer, String>]
231
+ def build_comment_map(comments)
232
+ map = {} #: Hash[Integer, String]
233
+ return map unless comments
234
+
235
+ comments.each do |comment|
236
+ map[comment.location.line] = comment.text
237
+ end
238
+ map
239
+ end
240
+
241
+ # @private
242
+ # @param [Parser::AST::Node] node
243
+ # @param [Docscribe::CLI::RbsGen::WalkContext] ctx
244
+ # @return [void]
245
+ def walk_for_methods(node, ctx)
246
+ return unless node.is_a?(Parser::AST::Node)
247
+
248
+ case node.type
249
+ when :class, :module then walk_class_module(node, ctx)
250
+ when :sclass then walk_sclass(node, ctx)
251
+ when :def then collect_def(node, ctx)
252
+ when :defs then collect_defs(node, ctx)
253
+ else walk_children(node, ctx)
254
+ end
255
+ end
256
+
257
+ # @private
258
+ # @param [Parser::AST::Node] node
259
+ # @param [Docscribe::CLI::RbsGen::WalkContext] ctx
260
+ # @return [void]
261
+ def walk_class_module(node, ctx)
262
+ ctx.containers.push(const_name(node.children[0]))
263
+ node.children.drop(1).each { |c| walk_for_methods(c, ctx) }
264
+ ctx.containers.pop
265
+ end
266
+
267
+ # @private
268
+ # @param [Parser::AST::Node] node
269
+ # @param [Docscribe::CLI::RbsGen::WalkContext] ctx
270
+ # @return [void]
271
+ def walk_sclass(node, ctx)
272
+ sc_ctx = WalkContext.new(
273
+ containers: ctx.containers,
274
+ method_defs: ctx.method_defs,
275
+ path: ctx.path,
276
+ comment_map: ctx.comment_map,
277
+ src_lines: ctx.src_lines,
278
+ inside_sclass: true
279
+ )
280
+ node.children.drop(1).each { |c| walk_for_methods(c, sc_ctx) }
281
+ end
282
+
283
+ # @private
284
+ # @param [Parser::AST::Node] node
285
+ # @param [Docscribe::CLI::RbsGen::WalkContext] ctx
286
+ # @return [void]
287
+ def walk_children(node, ctx)
288
+ node.children.each { |c| walk_for_methods(c, ctx) }
289
+ end
290
+
291
+ # @private
292
+ # @param [Parser::AST::Node] node
293
+ # @param [Docscribe::CLI::RbsGen::WalkContext] ctx
294
+ # @return [void]
295
+ def collect_def(node, ctx)
296
+ line = node.loc&.line || 1
297
+ yard_tags = parse_yard_tags_for_line(line, ctx)
298
+
299
+ ctx.method_defs << MethodDef.new(
300
+ name: node.children[0],
301
+ scope: ctx.inside_sclass ? :class : :instance,
302
+ container: container_name(ctx.containers),
303
+ file: ctx.path,
304
+ line: line,
305
+ yard_tags: yard_tags
306
+ )
307
+ end
308
+
309
+ # @private
310
+ # @param [Parser::AST::Node] node
311
+ # @param [Docscribe::CLI::RbsGen::WalkContext] ctx
312
+ # @return [void]
313
+ def collect_defs(node, ctx)
314
+ line = node.loc&.line || 1
315
+ yard_tags = parse_yard_tags_for_line(line, ctx)
316
+
317
+ ctx.method_defs << MethodDef.new(
318
+ name: node.children[1],
319
+ scope: :class,
320
+ container: container_name(ctx.containers),
321
+ file: ctx.path,
322
+ line: line,
323
+ yard_tags: yard_tags
324
+ )
325
+ end
326
+
327
+ # @private
328
+ # @param [Integer] line
329
+ # @param [Docscribe::CLI::RbsGen::WalkContext] ctx
330
+ # @return [Docscribe::CLI::RbsGen::YardTags?]
331
+ def parse_yard_tags_for_line(line, ctx)
332
+ yard_block = find_yard_block(line, ctx.comment_map, ctx.src_lines)
333
+ yard_block.any? ? parse_yard_tags(yard_block) : nil
334
+ end
335
+
336
+ # @private
337
+ # @param [Integer] line
338
+ # @param [Hash<Integer, String>] comment_map
339
+ # @param [Array<String>] src_lines
340
+ # @return [Array<String>]
341
+ def find_yard_block(line, comment_map, src_lines)
342
+ block = [] #: Array[String]
343
+ idx = line - 2
344
+ while idx >= 0
345
+ break if src_lines[idx].to_s.strip.empty?
346
+
347
+ block.unshift(comment_map[idx + 1]) if comment_map.key?(idx + 1)
348
+ break unless comment_map.key?(idx + 1) || block.empty?
349
+
350
+ idx -= 1
351
+ end
352
+ block
353
+ end
354
+
355
+ # @private
356
+ # @param [Array<String>] comment_lines
357
+ # @return [Docscribe::CLI::RbsGen::YardTags]
358
+ def parse_yard_tags(comment_lines)
359
+ params = [] #: Array[ParamTag]
360
+ opts = [] #: Array[ParamTag]
361
+ state = { params: params, options: opts, return_type: nil }
362
+ comment_lines.each { |line| parse_yard_line(line, state) }
363
+ return_type = state[:return_type] #: String?
364
+ YardTags.new(params: params, return_type: return_type, options: opts)
365
+ end
366
+
367
+ # @private
368
+ # @param [String] line
369
+ # @param [Hash<Symbol, Object>] state
370
+ # @return [void]
371
+ def parse_yard_line(line, state)
372
+ text = line.sub(/\A#\s*/, '')
373
+ parse_param_tag(text, state) || parse_option_tag(text, state) || parse_return_tag(text, state)
374
+ end
375
+
376
+ # @private
377
+ # @param [String] text
378
+ # @param [Hash<Symbol, Object>] state
379
+ # @return [void]
380
+ def parse_param_tag(text, state)
381
+ if (m = text.match(/\A@param\s+\[([^\]]+)\]\s+(\S+)\s*/))
382
+ state[:params] << ParamTag.new(name: m[2].to_s, type: m[1].to_s)
383
+ elsif (m = text.match(/\A@param\s+(\S+)\s+\[([^\]]+)\]\s*/))
384
+ state[:params] << ParamTag.new(name: m[1].to_s, type: m[2].to_s)
385
+ end
386
+ end
387
+
388
+ # @private
389
+ # @param [String] text
390
+ # @param [Hash<Symbol, Object>] state
391
+ # @return [void]
392
+ def parse_option_tag(text, state)
393
+ return unless (m = text.match(/\A@option\s+\S+\s+\[([^\]]+)\]\s+:?(\S+)\s*/))
394
+
395
+ state[:options] << ParamTag.new(name: m[2].to_s, type: m[1].to_s)
396
+ end
397
+
398
+ # @private
399
+ # @param [String] text
400
+ # @param [Hash<Symbol, Object>] state
401
+ # @return [void]
402
+ def parse_return_tag(text, state)
403
+ return unless (m = text.match(/\A@return\s+\[([^\]]+)\]\s*/))
404
+
405
+ state[:return_type] = m[1]
406
+ end
407
+
408
+ # @private
409
+ # @param [Array<String>] containers
410
+ # @return [String?]
411
+ def container_name(containers)
412
+ containers.empty? ? nil : containers.join('::')
413
+ end
414
+
415
+ # @private
416
+ # @param [Parser::AST::Node] node
417
+ # @return [String]
418
+ def const_name(node)
419
+ return node.to_s unless node.is_a?(Parser::AST::Node)
420
+ return node.children[1].to_s if node.type == :const
421
+
422
+ node.children.map { |c| c.is_a?(Parser::AST::Node) ? const_name(c) : c.to_s }.join('::')
423
+ end
424
+
425
+ # @private
426
+ # @param [Array<Docscribe::CLI::RbsGen::MethodDef>] method_defs
427
+ # @return [String]
428
+ def build_rbs_content(method_defs)
429
+ grouped = method_defs.group_by { |m| m.container || '' }
430
+
431
+ lines = [] #: Array[String]
432
+ grouped.each { |container, methods| append_group(lines, container, methods) }
433
+
434
+ "#{lines.join("\n")}\n"
435
+ end
436
+
437
+ # @private
438
+ # @param [Array<String>] lines
439
+ # @param [String] container
440
+ # @param [Array<Docscribe::CLI::RbsGen::MethodDef>] methods
441
+ # @return [void]
442
+ def append_group(lines, container, methods)
443
+ lines << '' unless lines.empty?
444
+ if container.empty?
445
+ methods.each { |m| lines << format_method_sig(m) }
446
+ else
447
+ lines << "class #{container}"
448
+ methods.each { |m| lines << " #{format_method_sig(m)}" }
449
+ lines << 'end'
450
+ end
451
+ end
452
+
453
+ # @private
454
+ # @param [Docscribe::CLI::RbsGen::MethodDef] method
455
+ # @return [String]
456
+ def format_method_sig(method)
457
+ prefix = method.scope == :class ? 'self.' : ''
458
+ ret = return_type_rbs(method)
459
+ param_strs = build_param_strs(method)
460
+
461
+ if param_strs.any?
462
+ "def #{prefix}#{method.name}: (#{param_strs.join(', ')}) -> #{ret}"
463
+ else
464
+ "def #{prefix}#{method.name}: () -> #{ret}"
465
+ end
466
+ end
467
+
468
+ # @private
469
+ # @param [Docscribe::CLI::RbsGen::MethodDef] method
470
+ # @return [Array<String>]
471
+ def build_param_strs(method)
472
+ tags = method.yard_tags
473
+ strs = (tags&.params || []).map { |p| "#{type_to_rbs(p.type)} #{p.name}" }
474
+ (tags&.options || []).each { |o| strs << "?#{type_to_rbs(o.type)} #{o.name}" }
475
+ strs
476
+ end
477
+
478
+ # @private
479
+ # @param [Docscribe::CLI::RbsGen::MethodDef] method
480
+ # @return [String]
481
+ def return_type_rbs(method)
482
+ tags = method.yard_tags
483
+ rt = tags&.return_type
484
+ return 'untyped' unless rt
485
+
486
+ type_to_rbs(rt)
487
+ end
488
+
489
+ # @private
490
+ # @param [String] yard_type
491
+ # @return [String]
492
+ def type_to_rbs(yard_type)
493
+ ast = Docscribe::Types::Yard.parse(yard_type)
494
+ Docscribe::Types::Yard::Formatter.to_rbs(ast)
495
+ end
496
+
497
+ # @private
498
+ # @param [String] content
499
+ # @param [String] source_path
500
+ # @param [Hash<Symbol, Object>] options
501
+ # @return [void]
502
+ def write_file(content, source_path, options)
503
+ out_path = rbs_output_path(source_path, options)
504
+ dir = File.dirname(out_path)
505
+
506
+ if File.exist?(out_path) && !options[:force]
507
+ warn "Skipping #{out_path} (use --force to overwrite)"
508
+ return
509
+ end
510
+
511
+ FileUtils.mkdir_p(dir)
512
+ File.write(out_path, content)
513
+ puts "Generated #{out_path}"
514
+ end
515
+
516
+ # @private
517
+ # @param [String] source_path
518
+ # @param [Hash<Symbol, Object>] options
519
+ # @return [String]
520
+ def rbs_output_path(source_path, options)
521
+ abs = File.expand_path(source_path)
522
+ pwd = File.expand_path(Dir.pwd)
523
+ rel = abs.start_with?(pwd) ? abs.sub("#{pwd}/", '') : File.basename(abs)
524
+ rel.sub(/\.rb\z/, '.rbs').then { |r| File.join(options[:output_dir], r) }
525
+ end
526
+ end
527
+ end
528
+ end
529
+ end