docscribe 1.0.0 → 1.2.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +692 -180
  3. data/exe/docscribe +2 -74
  4. data/lib/docscribe/cli/config_builder.rb +62 -0
  5. data/lib/docscribe/cli/init.rb +58 -0
  6. data/lib/docscribe/cli/options.rb +204 -0
  7. data/lib/docscribe/cli/run.rb +415 -0
  8. data/lib/docscribe/cli.rb +31 -0
  9. data/lib/docscribe/config/defaults.rb +71 -0
  10. data/lib/docscribe/config/emit.rb +126 -0
  11. data/lib/docscribe/config/filtering.rb +160 -0
  12. data/lib/docscribe/config/loader.rb +59 -0
  13. data/lib/docscribe/config/rbs.rb +51 -0
  14. data/lib/docscribe/config/sorbet.rb +87 -0
  15. data/lib/docscribe/config/sorting.rb +23 -0
  16. data/lib/docscribe/config/template.rb +176 -0
  17. data/lib/docscribe/config/utils.rb +102 -0
  18. data/lib/docscribe/config.rb +20 -230
  19. data/lib/docscribe/infer/ast_walk.rb +28 -0
  20. data/lib/docscribe/infer/constants.rb +11 -0
  21. data/lib/docscribe/infer/literals.rb +55 -0
  22. data/lib/docscribe/infer/names.rb +43 -0
  23. data/lib/docscribe/infer/params.rb +62 -0
  24. data/lib/docscribe/infer/raises.rb +68 -0
  25. data/lib/docscribe/infer/returns.rb +171 -0
  26. data/lib/docscribe/infer.rb +110 -259
  27. data/lib/docscribe/inline_rewriter/collector.rb +845 -0
  28. data/lib/docscribe/inline_rewriter/doc_block.rb +383 -0
  29. data/lib/docscribe/inline_rewriter/doc_builder.rb +605 -0
  30. data/lib/docscribe/inline_rewriter/source_helpers.rb +228 -0
  31. data/lib/docscribe/inline_rewriter/tag_sorter.rb +244 -0
  32. data/lib/docscribe/inline_rewriter.rb +604 -425
  33. data/lib/docscribe/parsing.rb +120 -0
  34. data/lib/docscribe/types/provider_chain.rb +37 -0
  35. data/lib/docscribe/types/rbs/provider.rb +213 -0
  36. data/lib/docscribe/types/rbs/type_formatter.rb +132 -0
  37. data/lib/docscribe/types/signature.rb +65 -0
  38. data/lib/docscribe/types/sorbet/base_provider.rb +217 -0
  39. data/lib/docscribe/types/sorbet/rbi_provider.rb +35 -0
  40. data/lib/docscribe/types/sorbet/source_provider.rb +25 -0
  41. data/lib/docscribe/version.rb +1 -1
  42. data/lib/docscribe.rb +1 -0
  43. metadata +85 -17
  44. data/.rspec +0 -3
  45. data/.rubocop.yml +0 -11
  46. data/.rubocop_todo.yml +0 -73
  47. data/CODE_OF_CONDUCT.md +0 -84
  48. data/Gemfile +0 -6
  49. data/Gemfile.lock +0 -73
  50. data/Rakefile +0 -12
  51. data/rakelib/docs.rake +0 -73
  52. data/stingray_docs_internal.gemspec +0 -41
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parser/source/range'
4
+
5
+ module Docscribe
6
+ module InlineRewriter
7
+ # Source-level helpers: ranges, insertion positions, indentation, and comment-block detection.
8
+ #
9
+ # These helpers operate on raw source text and parser source locations rather than Ruby semantics.
10
+ module SourceHelpers
11
+ module_function
12
+
13
+ # Extract the method name from a `:def` or `:defs` node.
14
+ #
15
+ # @note module_function: when included, also defines #node_name (instance visibility: private)
16
+ # @param [Parser::AST::Node] node
17
+ # @return [Symbol, nil]
18
+ def node_name(node)
19
+ case node.type
20
+ when :def then node.children[0]
21
+ when :defs then node.children[1]
22
+ end
23
+ end
24
+
25
+ # Return a zero-width range at the beginning of the line containing a node.
26
+ #
27
+ # Used as the insertion point for generated documentation.
28
+ #
29
+ # @note module_function: when included, also defines #line_start_range (instance visibility: private)
30
+ # @param [Parser::Source::Buffer] buffer
31
+ # @param [Parser::AST::Node] node
32
+ # @return [Parser::Source::Range]
33
+ def line_start_range(buffer, node)
34
+ start_pos = node.loc.expression.begin_pos
35
+ src = buffer.source
36
+ bol = start_pos <= 0 ? -1 : src.rindex("\n", start_pos - 1) || -1
37
+ Parser::Source::Range.new(buffer, bol + 1, bol + 1)
38
+ end
39
+
40
+ # Return structured information about a contiguous doc-like comment block above a method.
41
+ #
42
+ # Result includes:
43
+ # - all lines in the contiguous block
44
+ # - preserved directive prefix lines
45
+ # - editable doc lines
46
+ # - source positions for replacement
47
+ #
48
+ # Returns nil if no doc-like block is present.
49
+ #
50
+ # @note module_function: when included, also defines #doc_comment_block_info (instance visibility: private)
51
+ # @param [Parser::Source::Buffer] buffer
52
+ # @param [Integer] def_bol_pos beginning-of-line position of the target def
53
+ # @return [Hash, nil]
54
+ def doc_comment_block_info(buffer, def_bol_pos)
55
+ src = buffer.source
56
+ lines = src.lines
57
+ def_line_idx = src[0...def_bol_pos].count("\n")
58
+ i = def_line_idx - 1
59
+
60
+ # Skip blank lines directly above def
61
+ i -= 1 while i >= 0 && lines[i].strip.empty?
62
+
63
+ # Nearest non-blank line must be a comment
64
+ return nil unless i >= 0 && lines[i] =~ /^\s*#/
65
+
66
+ # Walk upward to include the entire contiguous comment block
67
+ end_idx = i
68
+ start_idx = i
69
+ start_idx -= 1 while start_idx >= 0 && lines[start_idx] =~ /^\s*#/
70
+ start_idx += 1
71
+
72
+ # Preserve leading directive-style comments
73
+ removable_start_idx = start_idx
74
+ removable_start_idx += 1 while removable_start_idx <= end_idx && preserved_comment_line?(lines[removable_start_idx])
75
+
76
+ return nil if removable_start_idx > end_idx
77
+
78
+ remaining = lines[removable_start_idx..end_idx]
79
+ return nil unless remaining.any? { |line| doc_marker_line?(line) }
80
+
81
+ start_pos = start_idx.positive? ? lines[0...start_idx].join.length : 0
82
+ doc_start_pos = removable_start_idx.positive? ? lines[0...removable_start_idx].join.length : 0
83
+ end_pos = lines[0..end_idx].join.length
84
+
85
+ {
86
+ lines: lines[start_idx..end_idx],
87
+ preserved_lines: lines[start_idx...removable_start_idx],
88
+ doc_lines: lines[removable_start_idx..end_idx],
89
+ start_pos: start_pos,
90
+ doc_start_pos: doc_start_pos,
91
+ end_pos: end_pos
92
+ }
93
+ end
94
+
95
+ # Compute the removable range for an existing doc-like block above a method.
96
+ #
97
+ # Preserved directive lines (such as RuboCop directives or magic comments) are excluded
98
+ # from the returned range.
99
+ #
100
+ # @note module_function: when included, also defines #comment_block_removal_range (instance visibility: private)
101
+ # @param [Parser::Source::Buffer] buffer
102
+ # @param [Integer] def_bol_pos beginning-of-line position of the target def
103
+ # @return [Parser::Source::Range, nil]
104
+ def comment_block_removal_range(buffer, def_bol_pos)
105
+ src = buffer.source
106
+ lines = src.lines
107
+ def_line_idx = src[0...def_bol_pos].count("\n")
108
+ i = def_line_idx - 1
109
+
110
+ # Skip blank lines directly above def
111
+ i -= 1 while i >= 0 && lines[i].strip.empty?
112
+
113
+ # Nearest non-blank line must be a comment to remove anything
114
+ return nil unless i >= 0 && lines[i] =~ /^\s*#/
115
+
116
+ # Walk upward to include the entire contiguous comment block
117
+ start_idx = i
118
+ start_idx -= 1 while start_idx >= 0 && lines[start_idx] =~ /^\s*#/
119
+ start_idx += 1
120
+
121
+ # Preserve leading directive-style comments (currently: rubocop directives)
122
+ removable_start_idx = start_idx
123
+ removable_start_idx += 1 while removable_start_idx <= i && preserved_comment_line?(lines[removable_start_idx])
124
+
125
+ # If the whole block is preserved directives, there is nothing to remove
126
+ return nil if removable_start_idx > i
127
+
128
+ # SAFETY: only remove if the remaining block looks like documentation
129
+ remaining = lines[removable_start_idx..i]
130
+ return nil unless remaining.any? { |line| doc_marker_line?(line) }
131
+
132
+ start_pos = removable_start_idx.positive? ? lines[0...removable_start_idx].join.length : 0
133
+ Parser::Source::Range.new(buffer, start_pos, def_bol_pos)
134
+ end
135
+
136
+ # Whether a comment line should be preserved during aggressive replacement.
137
+ #
138
+ # Preserved lines include:
139
+ # - RuboCop directives
140
+ # - Ruby magic comments
141
+ # - tool directives such as `:nocov:` / `:stopdoc:`
142
+ #
143
+ # @note module_function: when included, also defines #preserved_comment_line? (instance visibility: private)
144
+ # @param [String] line
145
+ # @return [Boolean]
146
+ def preserved_comment_line?(line)
147
+ # RuboCop directives
148
+ return true if line =~ /^\s*#\s*rubocop:(disable|enable|todo)\b/
149
+
150
+ # Ruby magic comments
151
+ return true if line =~ /^\s*#\s*(?:frozen_string_literal|warn_indent)\s*:\s*(?:true|false)\b/i
152
+ return true if line =~ /^\s*#.*\b(?:encoding|coding)\s*:\s*[\w.-]+\b/i
153
+
154
+ # Tool directives like:
155
+ # # :nocov:
156
+ # # :stopdoc:
157
+ # # :nodoc:
158
+ return true if line =~ /^\s*#\s*:\s*[\w-]+\s*:(?=\s|\z)/i
159
+
160
+ false
161
+ end
162
+
163
+ # Whether a comment line looks like documentation content.
164
+ #
165
+ # Recognized forms include:
166
+ # - Docscribe header lines
167
+ # - YARD tags/directives beginning with `@`
168
+ #
169
+ # @note module_function: when included, also defines #doc_marker_line? (instance visibility: private)
170
+ # @param [String] line
171
+ # @return [Boolean]
172
+ def doc_marker_line?(line)
173
+ # Docscribe header line:
174
+ # # +A#foo+ -> Integer
175
+ return true if line =~ /^\s*#\s*\+\S.*\+\s*->\s*\S/
176
+
177
+ # YARD tags and directives:
178
+ # # @param ...
179
+ # # @return ...
180
+ # # @raise ...
181
+ # # @private / @protected
182
+ # # @!attribute ...
183
+ # also matches indented attribute tag lines like:
184
+ # # @return [Type]
185
+ return true if line =~ /^\s*#\s*@/
186
+
187
+ false
188
+ end
189
+
190
+ # Whether any comment exists immediately above the insertion point.
191
+ #
192
+ # This helper is retained for compatibility/legacy behavior checks.
193
+ #
194
+ # @note module_function: when included, also defines #already_has_doc_immediately_above? (instance visibility: private)
195
+ # @param [Parser::Source::Buffer] buffer
196
+ # @param [Integer] insert_pos
197
+ # @return [Boolean]
198
+ def already_has_doc_immediately_above?(buffer, insert_pos)
199
+ src = buffer.source
200
+ lines = src.lines
201
+ current_line_index = src[0...insert_pos].count("\n")
202
+ i = current_line_index - 1
203
+ i -= 1 while i >= 0 && lines[i].strip.empty?
204
+ return false if i.negative?
205
+
206
+ !!(lines[i] =~ /^\s*#/)
207
+ end
208
+
209
+ # Return the indentation prefix of a node's source line.
210
+ #
211
+ # Tabs and spaces are preserved exactly.
212
+ #
213
+ # @note module_function: when included, also defines #line_indent (instance visibility: private)
214
+ # @param [Parser::AST::Node] node
215
+ # @raise [StandardError]
216
+ # @return [String]
217
+ def line_indent(node)
218
+ line = node.loc.expression.source_line
219
+ return '' unless line
220
+
221
+ # Preserve tabs/spaces exactly.
222
+ line[/\A[ \t]*/] || ''
223
+ rescue StandardError
224
+ ''
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docscribe
4
+ module InlineRewriter
5
+ # Older tag-sorting helper operating on comment-line segments.
6
+ #
7
+ # This module sorts contiguous runs of top-level tag entries and keeps related `@option`
8
+ # entries attached to their owning `@param`.
9
+ #
10
+ # If `DocBlock` fully supersedes this module in your codebase, consider removing it.
11
+ module TagSorter
12
+ module_function
13
+
14
+ # One sortable top-level tag entry plus its continuation lines.
15
+ # @!attribute [rw] tag
16
+ # @return [Object]
17
+ # @param [Object] value
18
+ #
19
+ # @!attribute [rw] lines
20
+ # @return [Object]
21
+ # @param [Object] value
22
+ #
23
+ # @!attribute [rw] param_name
24
+ # @return [Object]
25
+ # @param [Object] value
26
+ #
27
+ # @!attribute [rw] option_owner
28
+ # @return [Object]
29
+ # @param [Object] value
30
+ #
31
+ # @!attribute [rw] index
32
+ # @return [Object]
33
+ # @param [Object] value
34
+ Entry = Struct.new(:tag, :lines, :param_name, :option_owner, :index, keyword_init: true)
35
+
36
+ # Sort contiguous top-level tag runs according to configured tag order.
37
+ #
38
+ # Non-tag content is preserved as-is and acts as a sort boundary.
39
+ #
40
+ # @note module_function: when included, also defines #sort (instance visibility: private)
41
+ # @param [Array<String>] lines comment block lines
42
+ # @param [Array<String>] tag_order configured tag order
43
+ # @return [Array<String>]
44
+ def sort(lines, tag_order:)
45
+ priority = build_priority(tag_order)
46
+ segments = parse_segments(lines)
47
+ segments.flat_map { |seg| sort_segment(seg, priority: priority) }
48
+ end
49
+
50
+ # Build a tag priority map from configured tag order.
51
+ #
52
+ # @note module_function: when included, also defines #build_priority (instance visibility: private)
53
+ # @param [Array<String>] tag_order
54
+ # @return [Hash{String=>Integer}]
55
+ def build_priority(tag_order)
56
+ Array(tag_order).map { |t| t.to_s.sub(/\A@/, '') }
57
+ .each_with_index
58
+ .to_h
59
+ end
60
+
61
+ # Parse lines into sortable tag-run segments and non-sortable segments.
62
+ #
63
+ # @note module_function: when included, also defines #parse_segments (instance visibility: private)
64
+ # @param [Array<String>] lines
65
+ # @return [Array<Hash>]
66
+ def parse_segments(lines)
67
+ segments = []
68
+ i = 0
69
+
70
+ while i < lines.length
71
+ line = lines[i]
72
+
73
+ if top_level_tag_line?(line)
74
+ entries = []
75
+ while i < lines.length && top_level_tag_line?(lines[i])
76
+ entry, i = consume_entry(lines, i)
77
+ entries << entry
78
+ end
79
+ segments << { type: :tag_run, entries: entries }
80
+ else
81
+ segments << { type: :other, lines: [line] }
82
+ i += 1
83
+ end
84
+ end
85
+
86
+ segments
87
+ end
88
+
89
+ # Sort one parsed segment if it is a tag run.
90
+ #
91
+ # @note module_function: when included, also defines #sort_segment (instance visibility: private)
92
+ # @param [Hash] segment
93
+ # @param [Hash{String=>Integer}] priority
94
+ # @return [Array<String>]
95
+ def sort_segment(segment, priority:)
96
+ return segment[:lines] unless segment[:type] == :tag_run
97
+
98
+ groups = group_entries(segment[:entries])
99
+
100
+ groups
101
+ .each_with_index
102
+ .sort_by { |(group, idx)| [group_priority(group, priority), idx] }
103
+ .flat_map(&:first)
104
+ .flat_map(&:lines)
105
+ end
106
+
107
+ # Compute sort priority for a grouped tag entry.
108
+ #
109
+ # @note module_function: when included, also defines #group_priority (instance visibility: private)
110
+ # @param [Array<Entry>] group
111
+ # @param [Hash{String=>Integer}] priority
112
+ # @return [Integer]
113
+ def group_priority(group, priority)
114
+ first = group.first
115
+ priority.fetch(first.tag, priority.length)
116
+ end
117
+
118
+ # Consume one top-level tag entry and its continuation lines.
119
+ #
120
+ # @note module_function: when included, also defines #consume_entry (instance visibility: private)
121
+ # @param [Array<String>] lines
122
+ # @param [Integer] start_idx
123
+ # @return [Array<(Entry, Integer)>]
124
+ def consume_entry(lines, start_idx)
125
+ first = lines[start_idx]
126
+ tag = extract_tag_name(first)
127
+ entry_lines = [first]
128
+ i = start_idx + 1
129
+
130
+ while i < lines.length
131
+ line = lines[i]
132
+
133
+ break if top_level_tag_line?(line)
134
+ break if blank_comment_line?(line)
135
+ break unless comment_line?(line)
136
+
137
+ entry_lines << line
138
+ i += 1
139
+ end
140
+
141
+ entry = Entry.new(
142
+ tag: tag,
143
+ lines: entry_lines,
144
+ param_name: extract_param_name(first),
145
+ option_owner: extract_option_owner(first),
146
+ index: start_idx
147
+ )
148
+
149
+ [entry, i]
150
+ end
151
+
152
+ # Group entries so `@option` tags remain attached to their owning `@param`.
153
+ #
154
+ # @note module_function: when included, also defines #group_entries (instance visibility: private)
155
+ # @param [Array<Entry>] entries
156
+ # @return [Array<Array<Entry>>]
157
+ def group_entries(entries)
158
+ groups = []
159
+ i = 0
160
+
161
+ while i < entries.length
162
+ entry = entries[i]
163
+
164
+ if entry.tag == 'param'
165
+ group = [entry]
166
+ i += 1
167
+
168
+ while i < entries.length &&
169
+ entries[i].tag == 'option' &&
170
+ entries[i].option_owner &&
171
+ entries[i].option_owner == entry.param_name
172
+ group << entries[i]
173
+ i += 1
174
+ end
175
+
176
+ groups << group
177
+ else
178
+ groups << [entry]
179
+ i += 1
180
+ end
181
+ end
182
+
183
+ groups
184
+ end
185
+
186
+ # Whether a line begins a top-level tag entry.
187
+ #
188
+ # @note module_function: when included, also defines #top_level_tag_line? (instance visibility: private)
189
+ # @param [String] line
190
+ # @return [Boolean]
191
+ def top_level_tag_line?(line)
192
+ !!(line =~ /^\s*#\s*@\w+/)
193
+ end
194
+
195
+ # Whether a line is any comment line.
196
+ #
197
+ # @note module_function: when included, also defines #comment_line? (instance visibility: private)
198
+ # @param [String] line
199
+ # @return [Boolean]
200
+ def comment_line?(line)
201
+ !!(line =~ /^\s*#/)
202
+ end
203
+
204
+ # Whether a line is a blank comment separator.
205
+ #
206
+ # @note module_function: when included, also defines #blank_comment_line? (instance visibility: private)
207
+ # @param [String] line
208
+ # @return [Boolean]
209
+ def blank_comment_line?(line)
210
+ !!(line =~ /^\s*#\s*$/)
211
+ end
212
+
213
+ # Extract tag name from a top-level tag line without the leading `@`.
214
+ #
215
+ # @note module_function: when included, also defines #extract_tag_name (instance visibility: private)
216
+ # @param [String] line
217
+ # @return [String, nil]
218
+ def extract_tag_name(line)
219
+ line[/^\s*#\s*@(\w+)/, 1]
220
+ end
221
+
222
+ # Extract parameter name from a `@param` line.
223
+ #
224
+ # @note module_function: when included, also defines #extract_param_name (instance visibility: private)
225
+ # @param [String] line
226
+ # @return [String, nil]
227
+ def extract_param_name(line)
228
+ return Regexp.last_match(1) if line =~ /^\s*#\s*@param\b\s+\[[^\]]+\]\s+(\S+)/
229
+ return Regexp.last_match(1) if line =~ /^\s*#\s*@param\b\s+(\S+)\s+\[[^\]]+\]/
230
+
231
+ nil
232
+ end
233
+
234
+ # Extract owning options-hash param name from an `@option` line.
235
+ #
236
+ # @note module_function: when included, also defines #extract_option_owner (instance visibility: private)
237
+ # @param [String] line
238
+ # @return [String, nil]
239
+ def extract_option_owner(line)
240
+ line[/^\s*#\s*@option\b\s+(\S+)/, 1]
241
+ end
242
+ end
243
+ end
244
+ end