boondoc 0.1.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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +17 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +345 -0
  6. data/exe/boondoc +6 -0
  7. data/lib/boondoc/assets.rb +112 -0
  8. data/lib/boondoc/cli.rb +99 -0
  9. data/lib/boondoc/config.rb +201 -0
  10. data/lib/boondoc/containment.rb +40 -0
  11. data/lib/boondoc/context.rb +48 -0
  12. data/lib/boondoc/core_constants.rb +23 -0
  13. data/lib/boondoc/docstring_parser.rb +119 -0
  14. data/lib/boondoc/filter.rb +34 -0
  15. data/lib/boondoc/frontend/comments.rb +67 -0
  16. data/lib/boondoc/frontend/prism_parser.rb +40 -0
  17. data/lib/boondoc/frontend/visitor.rb +359 -0
  18. data/lib/boondoc/generator.rb +116 -0
  19. data/lib/boondoc/linker.rb +175 -0
  20. data/lib/boondoc/links.rb +78 -0
  21. data/lib/boondoc/markdown.rb +90 -0
  22. data/lib/boondoc/model/code_object.rb +165 -0
  23. data/lib/boondoc/model/docstring.rb +70 -0
  24. data/lib/boondoc/model/registry.rb +150 -0
  25. data/lib/boondoc/path_map.rb +108 -0
  26. data/lib/boondoc/preview.rb +81 -0
  27. data/lib/boondoc/rake_task.rb +39 -0
  28. data/lib/boondoc/references.rb +59 -0
  29. data/lib/boondoc/renderer/base.rb +50 -0
  30. data/lib/boondoc/renderer/docstring.rb +140 -0
  31. data/lib/boondoc/renderer/index.rb +49 -0
  32. data/lib/boondoc/renderer/method_section.rb +84 -0
  33. data/lib/boondoc/renderer/namespace_page.rb +165 -0
  34. data/lib/boondoc/reporter.rb +36 -0
  35. data/lib/boondoc/stitcher.rb +89 -0
  36. data/lib/boondoc/type_parser.rb +78 -0
  37. data/lib/boondoc/version.rb +5 -0
  38. data/lib/boondoc/writer.rb +163 -0
  39. data/lib/boondoc/yard_bridge.rb +113 -0
  40. data/lib/boondoc/yardopts.rb +165 -0
  41. data/lib/boondoc.rb +48 -0
  42. metadata +116 -0
@@ -0,0 +1,359 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boondoc
4
+ module Frontend
5
+ # Walks a Prism tree and registers namespaces, methods, and constants.
6
+ #
7
+ # Method bodies are never entered, so only definitions made at class, module
8
+ # or top level are documented.
9
+ class Visitor < ::Prism::Visitor # rubocop:disable Metrics/ClassLength -- one handler per Ruby construct
10
+ # The state that applies while visiting one namespace body.
11
+ Frame = Struct.new(:namespace, :visibility, :singleton, :module_function)
12
+
13
+ CALLS = {
14
+ attr: :on_attr, attr_reader: :on_attr, attr_writer: :on_attr, attr_accessor: :on_attr,
15
+ public: :on_visibility, protected: :on_visibility, private: :on_visibility,
16
+ private_class_method: :on_class_visibility, public_class_method: :on_class_visibility,
17
+ module_function: :on_module_function, private_constant: :on_private_constant,
18
+ alias_method: :on_alias_method, define_method: :on_define_method,
19
+ include: :on_mixin, extend: :on_mixin, prepend: :on_mixin
20
+ }.freeze
21
+ ALWAYS_PRIVATE = %w[initialize_copy initialize_clone initialize_dup respond_to_missing?].freeze
22
+ ATTR_READERS_ONLY = %i[attr attr_reader].freeze
23
+ ANONYMOUS = { 'Struct' => :new, 'Data' => :define, 'Class' => :new, 'Module' => :new }.freeze
24
+
25
+ # @param registry [Model::Registry]
26
+ # @param file [String] file name recorded on each object
27
+ # @param comments [Comments]
28
+ def initialize(registry, file, comments)
29
+ super()
30
+ @registry = registry
31
+ @file = file
32
+ @comments = comments
33
+ @frames = [Frame.new(registry.root, :public, false, false)]
34
+ @visibility_override = nil
35
+ end
36
+
37
+ def visit_module_node(node)
38
+ open_namespace(node, :module, nil)
39
+ end
40
+
41
+ def visit_class_node(node)
42
+ open_namespace(node, :class, node.superclass&.slice)
43
+ end
44
+
45
+ def visit_singleton_class_node(node)
46
+ return unless node.expression.is_a?(::Prism::SelfNode)
47
+
48
+ with_frame(frame.namespace, singleton: true) { visit(node.body) }
49
+ end
50
+
51
+ def visit_def_node(node)
52
+ explicit = !node.receiver.nil?
53
+ scope = def_scope(node)
54
+ return unless scope
55
+
56
+ add_method(node.name.to_s, scope, line(node), parameters: node.parameters&.slice, explicit: explicit)
57
+ end
58
+
59
+ def visit_call_node(node)
60
+ handler = CALLS[node.name]
61
+ return super unless handler && (node.receiver.nil? || node.receiver.is_a?(::Prism::SelfNode))
62
+
63
+ send(handler, node)
64
+ end
65
+
66
+ def visit_alias_method_node(node)
67
+ return unless node.new_name.is_a?(::Prism::SymbolNode) && node.old_name.is_a?(::Prism::SymbolNode)
68
+
69
+ add_alias(node.new_name.unescaped, node.old_name.unescaped, line(node))
70
+ end
71
+
72
+ def visit_constant_write_node(node)
73
+ return if @comments.nodoc?(line(node))
74
+
75
+ value = node.value
76
+ return define_anonymous(node, value) if anonymous_namespace?(value)
77
+
78
+ constant = Model::ConstantObject.new(node.name.to_s, frame.namespace, value: value.slice)
79
+ @registry.register(constant)
80
+ document(constant, line(node))
81
+ end
82
+
83
+ private
84
+
85
+ def frame
86
+ @frames.last
87
+ end
88
+
89
+ def line(node)
90
+ node.location.start_line
91
+ end
92
+
93
+ # The scope of methods defined by calls such as `attr_reader` and `alias_method`.
94
+ def current_scope
95
+ frame.singleton ? :class : :instance
96
+ end
97
+
98
+ def find_method(scope, name)
99
+ @registry[Model::MethodObject.path_for(frame.namespace, scope, name)]
100
+ end
101
+
102
+ def with_frame(namespace, singleton: false)
103
+ @frames.push(Frame.new(namespace, :public, singleton, false))
104
+ yield
105
+ ensure
106
+ @frames.pop
107
+ end
108
+
109
+ def with_visibility(visibility)
110
+ previous = @visibility_override
111
+ @visibility_override = visibility
112
+ yield
113
+ ensure
114
+ @visibility_override = previous
115
+ end
116
+
117
+ # --- namespaces ---------------------------------------------------------
118
+
119
+ def open_namespace(node, kind, superclass)
120
+ return if @comments.nodoc?(line(node))
121
+
122
+ namespace = ensure_namespace(node.constant_path.slice, kind)
123
+ namespace.superclass ||= superclass
124
+ document(namespace, line(node))
125
+ with_frame(namespace) { visit(node.body) }
126
+ end
127
+
128
+ def ensure_namespace(name, kind)
129
+ parts = name.delete_prefix('::').split('::')
130
+ parent = namespace_base(name, parts)
131
+ parts.each_with_index do |part, index|
132
+ last = index == parts.size - 1
133
+ parent = child_namespace(parent, part, last ? kind : :module, placeholder: !last)
134
+ end
135
+ parent
136
+ end
137
+
138
+ # Where a (possibly qualified) name is looked up from: the root for `::Foo`,
139
+ # the namespace that already holds the first segment for `Foo::Bar`, and
140
+ # the current namespace otherwise.
141
+ def namespace_base(name, parts)
142
+ return @registry.root if name.start_with?('::')
143
+ return frame.namespace if parts.size == 1
144
+
145
+ @registry.resolve(frame.namespace, parts.first)&.namespace || frame.namespace
146
+ end
147
+
148
+ def child_namespace(parent, name, kind, placeholder:)
149
+ path = parent.root? ? name : "#{parent.path}::#{name}"
150
+ existing = @registry[path]
151
+ if existing.is_a?(Model::NamespaceObject)
152
+ existing.kind = kind unless placeholder
153
+ return existing
154
+ end
155
+
156
+ @registry.register(Model::NamespaceObject.new(name, parent, kind: kind))
157
+ end
158
+
159
+ def anonymous_namespace?(value)
160
+ value.is_a?(::Prism::CallNode) && value.receiver &&
161
+ ANONYMOUS[value.receiver.slice.delete_prefix('::')] == value.name
162
+ end
163
+
164
+ # `Foo = Struct.new(:a)`, `Foo = Data.define(:a)`, `Foo = Class.new(Base)`
165
+ def define_anonymous(node, value)
166
+ receiver = value.receiver.slice.delete_prefix('::')
167
+ namespace = ensure_namespace(node.name.to_s, receiver == 'Module' ? :module : :class)
168
+ namespace.superclass ||= anonymous_superclass(receiver, value)
169
+ document(namespace, line(node))
170
+ with_frame(namespace) do
171
+ define_members(receiver, value, line(node))
172
+ visit(value.block.body) if value.block.is_a?(::Prism::BlockNode)
173
+ end
174
+ end
175
+
176
+ def anonymous_superclass(receiver, value)
177
+ return receiver if %w[Struct Data].include?(receiver)
178
+ return nil unless receiver == 'Class'
179
+
180
+ first = arguments(value).first
181
+ first.slice if first.is_a?(::Prism::ConstantReadNode) || first.is_a?(::Prism::ConstantPathNode)
182
+ end
183
+
184
+ def define_members(receiver, value, line)
185
+ return unless %w[Struct Data].include?(receiver)
186
+
187
+ arguments(value).grep(::Prism::SymbolNode).each do |member|
188
+ add_method(member.unescaped, :instance, line, attribute: :reader, documented: false)
189
+ next if receiver == 'Data'
190
+
191
+ add_method("#{member.unescaped}=", :instance, line, attribute: :writer, documented: false)
192
+ end
193
+ end
194
+
195
+ # --- methods ------------------------------------------------------------
196
+
197
+ def def_scope(node)
198
+ case node.receiver
199
+ when nil then frame.singleton || frame.module_function ? :class : :instance
200
+ when ::Prism::SelfNode then :class
201
+ else :class if [frame.namespace.name, frame.namespace.path].include?(node.receiver.slice)
202
+ end
203
+ end
204
+
205
+ def add_method(name, scope, line, explicit: false, documented: true, **)
206
+ return if @comments.nodoc?(line)
207
+
208
+ meth = Model::MethodObject.new(name, frame.namespace, scope: scope, **)
209
+ meth.visibility = method_visibility(name, scope, explicit)
210
+ previous = @registry[meth.path]
211
+ @registry.register(meth)
212
+ meth.add_file(@file, line)
213
+ document(meth, line) if documented
214
+ meth.docstring = previous.docstring if previous && meth.docstring.empty?
215
+ meth
216
+ end
217
+
218
+ def method_visibility(name, scope, explicit)
219
+ return @visibility_override if @visibility_override
220
+ return :private if scope == :instance && ALWAYS_PRIVATE.include?(name)
221
+ return :public if explicit || frame.module_function
222
+
223
+ frame.visibility
224
+ end
225
+
226
+ def add_alias(new_name, old_name, line)
227
+ scope = current_scope
228
+ original = find_method(scope, old_name)
229
+ meth = add_method(new_name, scope, line, parameters: original&.parameters, alias_for: old_name)
230
+ return unless meth && original
231
+
232
+ # An alias copies the original's visibility, unless wrapped as in `private alias_method ...`.
233
+ meth.visibility = original.visibility unless @visibility_override
234
+ meth.docstring = original.docstring if meth.docstring.empty?
235
+ end
236
+
237
+ # --- call handlers ------------------------------------------------------
238
+
239
+ def on_attr(node)
240
+ scope = current_scope
241
+ names(node).each do |name|
242
+ add_method(name, scope, line(node), attribute: :reader) unless node.name == :attr_writer
243
+ next if ATTR_READERS_ONLY.include?(node.name)
244
+
245
+ add_method("#{name}=", scope, line(node), attribute: :writer)
246
+ end
247
+ end
248
+
249
+ def on_visibility(node)
250
+ args = arguments(node)
251
+ if args.empty?
252
+ frame.visibility = node.name
253
+ frame.module_function = false
254
+ return
255
+ end
256
+
257
+ apply_visibility(args, node.name, current_scope)
258
+ end
259
+
260
+ def on_class_visibility(node)
261
+ visibility = node.name == :private_class_method ? :private : :public
262
+ apply_visibility(arguments(node), visibility, :class)
263
+ end
264
+
265
+ def apply_visibility(args, visibility, scope)
266
+ args.each do |arg|
267
+ case arg
268
+ when ::Prism::ArrayNode then apply_visibility(arg.elements, visibility, scope)
269
+ when ::Prism::DefNode, ::Prism::CallNode then with_visibility(visibility) { visit(arg) }
270
+ else change_visibility(literal(arg), visibility, scope)
271
+ end
272
+ end
273
+ end
274
+
275
+ def change_visibility(name, visibility, scope)
276
+ meth = name && find_method(scope, name)
277
+ meth.visibility = visibility if meth
278
+ end
279
+
280
+ def on_module_function(node)
281
+ args = arguments(node)
282
+ return frame.module_function = true if args.empty?
283
+
284
+ args.each do |arg|
285
+ next promote_to_class_method(literal(arg)) unless arg.is_a?(::Prism::DefNode)
286
+
287
+ # A bare `module_function` earlier in the body stays in effect afterwards.
288
+ previous = frame.module_function
289
+ frame.module_function = true
290
+ visit(arg)
291
+ frame.module_function = previous
292
+ end
293
+ end
294
+
295
+ def promote_to_class_method(name)
296
+ original = name && find_method(:instance, name)
297
+ return unless original
298
+
299
+ @registry.delete(original)
300
+ meth = Model::MethodObject.new(name, frame.namespace, scope: :class, parameters: original.parameters)
301
+ meth.docstring = original.docstring
302
+ original.files.each { |file, line| meth.add_file(file, line) }
303
+ @registry.register(meth)
304
+ end
305
+
306
+ def on_private_constant(node)
307
+ names(node).each do |name|
308
+ constant = @registry[frame.namespace.root? ? name : "#{frame.namespace.path}::#{name}"]
309
+ constant.visibility = :private if constant
310
+ end
311
+ end
312
+
313
+ def on_alias_method(node)
314
+ new_name, old_name = names(node)
315
+ add_alias(new_name, old_name, line(node)) if new_name && old_name
316
+ end
317
+
318
+ def on_define_method(node)
319
+ name = literal(arguments(node).first)
320
+ return unless name && node.block.is_a?(::Prism::BlockNode)
321
+
322
+ add_method(name, current_scope, line(node), parameters: node.block.parameters&.parameters&.slice)
323
+ end
324
+
325
+ def on_mixin(node)
326
+ kind = frame.singleton && node.name == :include ? :extend : node.name
327
+ arguments(node).each do |arg|
328
+ next unless arg.is_a?(::Prism::ConstantReadNode) || arg.is_a?(::Prism::ConstantPathNode)
329
+
330
+ list = frame.namespace.mixins[kind]
331
+ list << arg.slice unless list.include?(arg.slice)
332
+ end
333
+ end
334
+
335
+ # --- helpers ------------------------------------------------------------
336
+
337
+ def arguments(node)
338
+ node.arguments&.arguments || []
339
+ end
340
+
341
+ def names(node)
342
+ arguments(node).filter_map { |arg| literal(arg) }
343
+ end
344
+
345
+ def literal(node)
346
+ node.unescaped if node.is_a?(::Prism::SymbolNode) || node.is_a?(::Prism::StringNode)
347
+ end
348
+
349
+ def document(object, line)
350
+ object.add_file(@file, line)
351
+ text = @comments.docstring_for(line)
352
+ return unless text
353
+
354
+ docstring = DocstringParser.parse(text)
355
+ object.docstring = docstring if object.docstring.empty?
356
+ end
357
+ end
358
+ end
359
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boondoc
4
+ # Runs the whole pipeline: parse, resolve, render, write.
5
+ #
6
+ # @example
7
+ # Boondoc::Generator.new(Boondoc::Config.new(output: 'docs')).run
8
+ class Generator
9
+ # What a run produced.
10
+ Result = Struct.new(:pages, :assets, :removed, :warnings, keyword_init: true)
11
+
12
+ # @return [Model::Registry] the registry built by the last {#run}
13
+ attr_reader :registry
14
+
15
+ # @param config [Config]
16
+ # @param reporter [Reporter]
17
+ def initialize(config, reporter: Reporter.new(quiet: config.quiet))
18
+ @config = config
19
+ @reporter = reporter
20
+ end
21
+
22
+ # @return [Result]
23
+ # @raise [Error] when no source files match, the output directory is unsafe,
24
+ # a file would be overwritten, or `strict` is set and there were warnings
25
+ def run
26
+ report_config
27
+ files = @config.source_files
28
+ raise Error, "no Ruby files matched #{Array(@config.sources).join(', ')}" if files.empty?
29
+
30
+ parse(files)
31
+ pages = render
32
+ removed = Writer.new(@config.output_dir, @reporter, force: @config.force).write(pages, assets: @assets)
33
+ finish(pages, @assets, removed)
34
+ end
35
+
36
+ # Builds every page without writing anything.
37
+ #
38
+ # @return [Hash{String => String}] page content keyed by path relative to the output directory
39
+ def render
40
+ context = build_context
41
+ pages = pages_for(context)
42
+ @assets = context.assets.files # linked assets are found while the pages render
43
+ pages
44
+ end
45
+
46
+ # Parses the given files into a fresh registry.
47
+ #
48
+ # @param files [Array<String>] paths relative to the project root
49
+ # @return [Model::Registry]
50
+ def parse(files)
51
+ @registry = Model::Registry.new
52
+ parser = Frontend::PrismParser.new(@registry, @reporter)
53
+ files.each { |file| parser.parse_file(File.expand_path(file, @config.root), label: file) }
54
+ expand_directives(files)
55
+ References.new(@registry).expand
56
+ @registry
57
+ end
58
+
59
+ private
60
+
61
+ def report_config
62
+ @config.output_dir # refuse an unsafe output directory before doing any work
63
+ @config.yardopts_notes.each { |note| @reporter.info(note) }
64
+ @config.outside_sources.each do |path|
65
+ @reporter.warn("skipping #{path}: it is outside the project (#{@config.root})")
66
+ end
67
+ end
68
+
69
+ def expand_directives(files)
70
+ if @config.yard_macros
71
+ merge_yard(files & @registry.directive_files)
72
+ elsif @registry.directive_sites.any?
73
+ sites = @registry.directive_sites
74
+ @reporter.warn("#{sites.size} comment(s) use YARD directives (@!macro, @!method, ...), which boondoc " \
75
+ "does not expand on its own; rerun with --yard-macros. First: #{sites.first(3).join(', ')}")
76
+ end
77
+ end
78
+
79
+ def merge_yard(yard_files)
80
+ added = yard_files.empty? ? 0 : YardBridge.new(@registry, @reporter).merge(yard_files, root: @config.root)
81
+ @reporter.info("yard: added #{added} object(s) from macros and directives")
82
+ return unless yard_files.empty?
83
+
84
+ @reporter.warn('yard: no YARD macros or directives were found; disable yard_macros ' \
85
+ '(--yard-macros) for this project')
86
+ end
87
+
88
+ def pages_for(context)
89
+ index = Renderer::Index.new(context)
90
+ pages = { @config.index => index.render }
91
+ context.stitcher.files.reject(&:readme).each { |file| pages[file.output] = index.render_file(file) }
92
+ namespace_pages(context).each { |namespace, page| pages[context.path_map.file_for(namespace)] = page.render }
93
+ pages
94
+ end
95
+
96
+ def build_context
97
+ Context.new(config: @config, registry: @registry, reporter: @reporter)
98
+ end
99
+
100
+ def namespace_pages(context)
101
+ namespaces = @registry.grep(Model::NamespaceObject).select { |namespace| context.filter.visible?(namespace) }
102
+ namespaces.to_h { |namespace| [namespace, Renderer::NamespacePage.new(context, namespace)] }
103
+ .select { |_, page| page.content? }
104
+ end
105
+
106
+ def finish(pages, assets, removed)
107
+ warnings = @reporter.warnings
108
+ @reporter.info("boondoc: wrote #{pages.size} file(s) and #{assets.size} asset(s) to #{@config.output}" \
109
+ "#{", removed #{removed.size} stale" if removed.any?}" \
110
+ "#{", #{warnings.size} warning(s)" if warnings.any?}")
111
+ raise Error, "#{warnings.size} warning(s) in strict mode" if @config.strict && warnings.any?
112
+
113
+ Result.new(pages: pages.keys.sort, assets: assets.keys.sort, removed: removed, warnings: warnings)
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boondoc
4
+ # Turns YARD-style references in markdown into relative links.
5
+ #
6
+ # Handles `{Foo#bar}`, `{Foo#bar custom text}`, `{https://example.com text}`,
7
+ # `{file:GUIDE.md}`, and the include forms `{include:Foo}` and
8
+ # `{include:file:path.md}`. Code blocks and code spans are left untouched.
9
+ class Linker # rubocop:disable Metrics/ClassLength -- references and asset links resolved in one place
10
+ REFERENCE = /(?<escape>\\)?\{(?<target>[^{}\s]+)(?:\s+(?<title>[^{}]+?))?\}/
11
+ INCLUDE = /\{include:(?<file>file:)?(?<target>[^{}\s]+)\}/
12
+ URL = %r{\A(?:[a-z][a-z0-9+.-]*://|mailto:)}i
13
+ CONSTANT = /(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*/
14
+ CONSTANT_REFERENCE = /\A#{CONSTANT}(?:[#.].+)?\z/
15
+ METHOD_REFERENCE = /\A[#.][^\s]/
16
+ MAX_INCLUDE_DEPTH = 5
17
+
18
+ # @param config [Config]
19
+ # @param registry [Model::Registry]
20
+ # @param filter [Filter]
21
+ # @param path_map [PathMap]
22
+ # @param stitcher [Stitcher]
23
+ # @param assets [Assets] the files copied into the output, which image paths may name
24
+ # @param reporter [Reporter]
25
+ def initialize(config:, registry:, filter:, path_map:, stitcher:, reporter:, assets: Assets.new(config))
26
+ @config = config
27
+ @registry = registry
28
+ @filter = filter
29
+ @path_map = path_map
30
+ @stitcher = stitcher
31
+ @assets = assets
32
+ @reporter = reporter
33
+ end
34
+
35
+ # Expands includes and resolves references in a piece of markdown, then points
36
+ # paths to copied assets at their copies.
37
+ #
38
+ # @param text [String]
39
+ # @param object [Model::CodeObject] where the text was written; references
40
+ # are looked up relative to it
41
+ # @param from [String] the output file the text will appear in
42
+ # @param assets [Boolean] whether to rewrite asset paths; false for stitched files,
43
+ # whose paths {Stitcher} has already rewritten
44
+ # @return [String]
45
+ def text(text, object:, from:, assets: true)
46
+ text = Markdown.map_prose(expand_includes(text.to_s, object, 0)) do |prose|
47
+ prose.gsub(REFERENCE) { reference(Regexp.last_match, object, from) }
48
+ end
49
+ return text unless assets
50
+
51
+ Links.rewrite(text) { |target, embedded| asset_target(target, from, embedded) }
52
+ end
53
+
54
+ # @param object [Model::CodeObject]
55
+ # @param from [String] the output file the link is written in
56
+ # @param label [String, nil] link text; defaults to the object's path in a code span
57
+ # @return [String] a markdown link, or just the label when the object is not rendered
58
+ def link(object, from:, label: nil)
59
+ label ||= "`#{object.path}`"
60
+ @filter.visible?(object) ? "[#{label}](#{@path_map.href(object, from: from)})" : label
61
+ end
62
+
63
+ # Formats a type list, linking every constant that is documented.
64
+ #
65
+ # @param types [Array<String>, nil]
66
+ # @param object [Model::CodeObject]
67
+ # @param from [String]
68
+ # @return [String, nil] such as `` `Array<`[`Foo`](Foo.md)`>`, `nil` ``
69
+ def types(types, object:, from:)
70
+ return nil if types.nil? || types.empty?
71
+
72
+ types.map { |type| type_markup(type, object, from) }.join(', ')
73
+ end
74
+
75
+ private
76
+
77
+ def expand_includes(text, object, depth)
78
+ return text if depth > MAX_INCLUDE_DEPTH || !text.include?('{include:')
79
+
80
+ Markdown.map_prose(text) do |prose|
81
+ prose.gsub(INCLUDE) { included(Regexp.last_match, object, depth) }
82
+ end
83
+ end
84
+
85
+ def included(match, object, depth)
86
+ content = match[:file] ? read_file(match[:target]) : @registry.resolve(object, match[:target])&.docstring&.text
87
+ unless content
88
+ @reporter.warn("#{location(object)}: cannot include #{match[0]}")
89
+ return match[0]
90
+ end
91
+
92
+ expand_includes(content.strip, object, depth + 1)
93
+ end
94
+
95
+ # Only files inside the project can be included.
96
+ def read_file(path)
97
+ full = File.expand_path(path, @config.root)
98
+ File.read(full) if File.file?(full) && Containment.inside?(full, @config.root)
99
+ end
100
+
101
+ def reference(match, object, from)
102
+ target = match[:target]
103
+ title = match[:title]&.strip
104
+ return match[0].delete_prefix('\\') if match[:escape]
105
+ return match[0] if target.start_with?('include:')
106
+ return "[#{title || target}](#{target})" if URL.match?(target)
107
+ return file_reference(match, target.delete_prefix('file:'), title, from) if target.start_with?('file:')
108
+
109
+ object_reference(match, title, object, from)
110
+ end
111
+
112
+ # A path in a docstring is relative to the output directory first, the way YARD's
113
+ # `--asset` paths are, and to the project second. It is left as written unless it
114
+ # names a file that is copied.
115
+ def asset_target(target, from, embedded)
116
+ path, suffix = target.split(/(?=[#?])/, 2)
117
+ copied = @assets.copy_for(path, embedded: embedded)
118
+ copied ? "#{@path_map.relative(copied, from)}#{suffix}" : target
119
+ end
120
+
121
+ def file_reference(match, name, title, from)
122
+ file = @stitcher.find(name)
123
+ return "[#{title || file.title}](#{@path_map.relative(file.output, from)})" if file
124
+
125
+ @reporter.warn("cannot resolve #{match[0]}: #{name} is not one of the stitched files")
126
+ match[0]
127
+ end
128
+
129
+ def object_reference(match, title, object, from)
130
+ target = match[:target]
131
+ found = @registry.resolve(object, target)
132
+ return link(found, from: from, label: title || "`#{target}`") if found
133
+ return match[0] unless reference_like?(target, object)
134
+
135
+ return title || "`#{target}`" if core?(target)
136
+
137
+ @reporter.warn("#{location(object)}: cannot resolve link to #{target}")
138
+ match[0]
139
+ end
140
+
141
+ # Braces in prose are only reported as a broken reference when they look
142
+ # like one; `{key: value}` and friends pass through silently. Either way
143
+ # the original text is left exactly as written.
144
+ def reference_like?(target, object)
145
+ CONSTANT_REFERENCE.match?(target) || (!object.root? && METHOD_REFERENCE.match?(target))
146
+ end
147
+
148
+ # References to Ruby's own classes cannot be linked, but are not mistakes.
149
+ def core?(target)
150
+ CORE_CONSTANTS.include?(target.delete_prefix('::')[/\A[A-Z]\w*/])
151
+ end
152
+
153
+ def location(object)
154
+ object.root? ? 'docs' : object.path
155
+ end
156
+
157
+ def type_markup(type, object, from)
158
+ parts = []
159
+ position = 0
160
+ type.scan(CONSTANT) do
161
+ match = Regexp.last_match
162
+ found = @registry.resolve(object, match[0])
163
+ next unless found && @filter.visible?(found)
164
+
165
+ parts << code(type[position...match.begin(0)]) << link(found, from: from, label: code(match[0]))
166
+ position = match.end(0)
167
+ end
168
+ (parts << code(type[position..])).compact.join
169
+ end
170
+
171
+ def code(text)
172
+ "`#{text}`" unless text.empty?
173
+ end
174
+ end
175
+ end