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,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boondoc
4
+ # Serves generated docs locally using the `gh markdown-preview` extension of
5
+ # the GitHub CLI.
6
+ #
7
+ # The previewer resolves links relative to the directory it was started in,
8
+ # so it is always started from inside the output directory.
9
+ class Preview
10
+ EXTENSION = 'yusukebe/gh-markdown-preview'
11
+ DEFAULT_HOST = '127.0.0.1'
12
+ LOOPBACK_HOSTS = %w[::1 localhost].freeze
13
+ SILENT = { out: File::NULL, err: File::NULL }.freeze
14
+
15
+ # @param directory [String] the directory holding the generated docs
16
+ # @param host [String] address the previewer binds to
17
+ # @param port [Integer, nil] port to listen on; the previewer's default when nil
18
+ # @yieldreturn [Boolean] whether the extension may be installed; asked only
19
+ # when it is missing
20
+ def initialize(directory, host: DEFAULT_HOST, port: nil, &confirm_install)
21
+ @directory = directory
22
+ @host = host
23
+ @port = port
24
+ @confirm_install = confirm_install
25
+ end
26
+
27
+ # Starts the previewer and blocks until it exits.
28
+ #
29
+ # @return [void]
30
+ # @raise [Error] when the docs, the GitHub CLI, or the extension are missing
31
+ def start
32
+ raise Error, "#{@directory} does not exist; run `boondoc` first to generate the docs" unless docs?
33
+ raise Error, 'serve needs the GitHub CLI (gh); see https://cli.github.com' unless gh?
34
+
35
+ install unless extension?
36
+ warn_exposed unless loopback?
37
+ serve
38
+ end
39
+
40
+ private
41
+
42
+ def loopback?
43
+ LOOPBACK_HOSTS.include?(@host) || @host.start_with?('127.')
44
+ end
45
+
46
+ # The extension answers `/__/md?path=` for any path, not only the docs, and
47
+ # does not check the Host header, so whoever can reach the port can read
48
+ # every file the current user can.
49
+ def warn_exposed
50
+ warn "boondoc: serving on #{@host} exposes the preview to the network. gh markdown-preview will " \
51
+ 'return any file the current user can read, not only the docs. Use the default 127.0.0.1 ' \
52
+ 'unless everyone who can reach this machine is trusted.'
53
+ end
54
+
55
+ def docs?
56
+ File.directory?(@directory)
57
+ end
58
+
59
+ def gh?
60
+ system('gh', '--version', **SILENT) == true
61
+ end
62
+
63
+ def extension?
64
+ system('gh', 'markdown-preview', '--version', **SILENT) == true
65
+ end
66
+
67
+ def install
68
+ manual = "install it with `gh extension install #{EXTENSION}`"
69
+ raise Error, "serve needs the gh markdown-preview extension; #{manual}" unless @confirm_install&.call
70
+ raise Error, "could not install the extension; #{manual}" unless system('gh', 'extension', 'install', EXTENSION)
71
+ end
72
+
73
+ def serve
74
+ command = ['gh', 'markdown-preview', '--host', @host]
75
+ command.push('--port', @port.to_s) if @port
76
+ system(*command, chdir: @directory)
77
+ rescue Interrupt
78
+ nil
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rake'
4
+ require 'rake/tasklib'
5
+ require 'boondoc'
6
+
7
+ module Boondoc
8
+ # Defines a Rake task that generates the documentation.
9
+ #
10
+ # @example
11
+ # require 'boondoc/rake_task'
12
+ #
13
+ # Boondoc::RakeTask.new(:docs) do |t|
14
+ # t.options = { output: 'docs', files: ['CHANGELOG.md'], strict: true }
15
+ # end
16
+ class RakeTask < ::Rake::TaskLib
17
+ # @return [Symbol] the task name
18
+ attr_accessor :name
19
+ # @return [Hash{Symbol => Object}] options passed to {Config.load}
20
+ attr_accessor :options
21
+
22
+ # @param name [Symbol]
23
+ # @yieldparam task [RakeTask] the task, for setting {#options}
24
+ def initialize(name = :docs)
25
+ super()
26
+ @name = name
27
+ @options = {}
28
+ yield self if block_given?
29
+ define
30
+ end
31
+
32
+ private
33
+
34
+ def define
35
+ desc 'Generate markdown documentation with boondoc'
36
+ task(name) { Generator.new(Config.load(options)).run }
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boondoc
4
+ # Expands YARD reference docstrings: `(see Foo#bar)` as a whole docstring, or
5
+ # as the text of a tag such as `@param (see Foo#bar)`.
6
+ class References
7
+ SEE = /\A\(see (\S+)\)\z/
8
+
9
+ # @param registry [Model::Registry]
10
+ def initialize(registry)
11
+ @registry = registry
12
+ end
13
+
14
+ # @return [void]
15
+ def expand
16
+ @visited = {}.compare_by_identity
17
+ @registry.each { |object| expand_object(object) }
18
+ end
19
+
20
+ private
21
+
22
+ # Targets are expanded before they are copied, so chains such as
23
+ # `(see B)` -> `(see C)` work in any order. A cycle stops at the object
24
+ # already being expanded.
25
+ def expand_object(object) # rubocop:disable Metrics/MethodLength
26
+ return if @visited.key?(object)
27
+
28
+ @visited[object] = true
29
+ docstring = object.docstring
30
+ tags = docstring.tags.flat_map { |tag| expand_tag(object, tag) }
31
+ text = docstring.text
32
+ target = referenced(object, text.strip)
33
+ if target
34
+ text = target.docstring.text
35
+ tags += inherited_tags(target, tags)
36
+ end
37
+ object.docstring = Model::Docstring.new(text, tags, docstring.directives)
38
+ end
39
+
40
+ def inherited_tags(target, own_tags)
41
+ own = own_tags.map(&:tag_name)
42
+ target.docstring.tags.reject { |tag| own.include?(tag.tag_name) }
43
+ end
44
+
45
+ def expand_tag(object, tag)
46
+ target = referenced(object, tag.text.to_s.strip)
47
+ target ? target.docstring.tags_named(tag.tag_name) : [tag]
48
+ end
49
+
50
+ def referenced(object, text)
51
+ match = SEE.match(text)
52
+ target = match && @registry.resolve(object, match[1])
53
+ return nil if target.nil? || target.equal?(object)
54
+
55
+ expand_object(target)
56
+ target
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boondoc
4
+ # Classes that turn the model into markdown.
5
+ module Renderer
6
+ # Shared helpers for all renderers.
7
+ class Base
8
+ # @param context [Context]
9
+ def initialize(context)
10
+ @context = context
11
+ end
12
+
13
+ private
14
+
15
+ attr_reader :context
16
+
17
+ def registry = context.registry
18
+ def filter = context.filter
19
+ def linker = context.linker
20
+ def path_map = context.path_map
21
+
22
+ def code(text)
23
+ "`#{text}`"
24
+ end
25
+
26
+ # A fenced Ruby block whose fence is longer than any fence inside the source.
27
+ def ruby(source)
28
+ longest = source.scan(/^\s*(`{3,})/).flatten.map(&:size).max || 2
29
+ fence = '`' * [3, longest + 1].max
30
+ "#{fence}ruby\n#{source}\n#{fence}"
31
+ end
32
+
33
+ # Joins blocks into a document with exactly one blank line between them.
34
+ def document(blocks)
35
+ blocks.flatten.compact.map { |block| block.sub(/\A\n+/, '').rstrip }.reject(&:empty?).join("\n\n") << "\n"
36
+ end
37
+
38
+ # A one-line summary of an object, with references resolved.
39
+ def summary(object, from)
40
+ text = linker.text(object.docstring.summary, object: object, from: from)
41
+ text = "**Deprecated.** #{text}".strip if object.docstring.tag?('deprecated')
42
+ Markdown.table_cell(text)
43
+ end
44
+
45
+ def bullet(link, summary)
46
+ summary.empty? ? "- #{link}" : "- #{link} — #{summary}"
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boondoc
4
+ module Renderer
5
+ # Renders a docstring: callouts, body, then one section per kind of tag.
6
+ class Docstring < Base
7
+ CALLOUTS = { 'deprecated' => 'Deprecated', 'abstract' => 'Abstract', 'note' => 'Note',
8
+ 'todo' => 'Todo' }.freeze
9
+ TYPED = { 'yieldparam' => 'Yield parameters', 'yieldreturn' => 'Yield returns',
10
+ 'return' => 'Returns', 'raise' => 'Raises' }.freeze
11
+ META = { 'since' => 'Since', 'version' => 'Version', 'author' => 'Author', 'api' => 'API' }.freeze
12
+ HANDLED = (CALLOUTS.keys + TYPED.keys + META.keys +
13
+ %w[param option yield example see overload private attr attr_reader attr_writer]).freeze
14
+
15
+ # @param context [Context]
16
+ # @param object [Model::CodeObject] the object being documented
17
+ # @param from [String] the output file being written
18
+ def initialize(context, object, from)
19
+ super(context)
20
+ @object = object
21
+ @from = from
22
+ end
23
+
24
+ # @param docstring [Model::Docstring]
25
+ # @return [Array<String>] markdown blocks, in order
26
+ def render(docstring)
27
+ [
28
+ callouts(docstring), text(docstring.text), parameters(docstring), yields(docstring),
29
+ TYPED.map { |name, title| section(title, docstring.tags_named(name).map { |tag| typed(tag) }) },
30
+ examples(docstring), see_also(docstring), meta(docstring), overloads(docstring)
31
+ ].flatten.compact.reject(&:empty?)
32
+ end
33
+
34
+ private
35
+
36
+ def text(text)
37
+ linker.text(text, object: @object, from: @from).strip
38
+ end
39
+
40
+ def types(tag)
41
+ linker.types(tag.types, object: @object, from: @from)
42
+ end
43
+
44
+ def callouts(docstring)
45
+ docstring.tags.select { |tag| CALLOUTS.key?(tag.tag_name) }.map do |tag|
46
+ body = text(tag.text)
47
+ label = "**#{CALLOUTS[tag.tag_name]}#{':' unless body.empty?}** #{body}".strip
48
+ label.lines.map { |line| "> #{line}".rstrip }.join("\n")
49
+ end
50
+ end
51
+
52
+ def section(title, items)
53
+ "**#{title}:**\n\n#{items.join("\n")}" unless items.empty?
54
+ end
55
+
56
+ def item(lead, description, indent: '')
57
+ line = [lead, description].compact.reject(&:empty?).join(' — ')
58
+ "#{indent}- #{line.gsub("\n", "\n#{indent} ")}"
59
+ end
60
+
61
+ def typed(tag)
62
+ types = types(tag)
63
+ item([tag.name && code(tag.name), types && "(#{types})"].compact.join(' '), text(tag.text))
64
+ end
65
+
66
+ def parameters(docstring)
67
+ params = docstring.tags_named('param')
68
+ options = docstring.tags_named('option')
69
+ items = params.flat_map { |tag| [typed(tag), *option_items(options, tag.name, ' ')] }
70
+ [section('Parameters', items), *orphan_options(options, params.map(&:name))]
71
+ end
72
+
73
+ # Options whose hash is not itself documented with @param get their own section.
74
+ def orphan_options(options, param_names)
75
+ orphans = options.reject { |option| param_names.include?(option.name) }
76
+ orphans.map(&:name).uniq.map do |name|
77
+ section("Options (#{code(name)})", option_items(orphans, name, ''))
78
+ end
79
+ end
80
+
81
+ def option_items(options, name, indent)
82
+ options.select { |option| option.name == name }.map do |option|
83
+ types = types(option)
84
+ lead = [code(option.key), types && "(#{types})"].compact.join(' ')
85
+ default = option.default && "(default: #{code(option.default)})"
86
+ item(lead, [text(option.text), default].compact.reject(&:empty?).join(' '), indent: indent)
87
+ end
88
+ end
89
+
90
+ def yields(docstring)
91
+ items = docstring.tags_named('yield').map do |tag|
92
+ names = tag.types&.map { |name| code(name) }&.join(', ')
93
+ item(names && "(#{names})", text(tag.text))
94
+ end
95
+ section('Yields', items)
96
+ end
97
+
98
+ def examples(docstring)
99
+ tags = docstring.tags_named('example')
100
+ return nil if tags.empty?
101
+
102
+ ['**Examples:**', *tags.flat_map { |tag| [("*#{tag.name}*" unless tag.name.to_s.empty?), fenced(tag.text)] }]
103
+ end
104
+
105
+ def fenced(source)
106
+ source.match?(/\A\s*(?:```|~~~)/) ? source : ruby(source)
107
+ end
108
+
109
+ def see_also(docstring)
110
+ section('See also', docstring.tags_named('see').map { |tag| item(see_link(tag.name.to_s), text(tag.text)) })
111
+ end
112
+
113
+ def see_link(name)
114
+ return "<#{name}>" if Linker::URL.match?(name)
115
+
116
+ found = registry.resolve(@object, name)
117
+ found ? linker.link(found, from: @from, label: code(name)) : text(name)
118
+ end
119
+
120
+ def meta(docstring)
121
+ known = META.flat_map { |name, title| docstring.tags_named(name).map { |tag| [title, tag] } }
122
+ other = docstring.tags.reject { |tag| HANDLED.include?(tag.tag_name) }
123
+ .map { |tag| [tag.tag_name.tr('_', ' ').capitalize, tag] }
124
+ items = (known + other).map { |title, tag| meta_item(title, tag) }
125
+ items.join("\n") unless items.empty?
126
+ end
127
+
128
+ def meta_item(title, tag)
129
+ types = types(tag)
130
+ "- **#{title}:** #{[types && "(#{types})", text(tag.text)].compact.join(' ')}".rstrip
131
+ end
132
+
133
+ def overloads(docstring)
134
+ docstring.tags_named('overload').flat_map do |tag|
135
+ ["**Overload:**\n\n#{ruby("def #{tag.signature}")}", *render(tag.docstring)]
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boondoc
4
+ module Renderer
5
+ # Renders the index page (README plus tables of contents) and stitched files.
6
+ class Index < Base
7
+ # @return [String] the index page
8
+ def render
9
+ from = context.config.index
10
+ readme = context.stitcher.readme
11
+ intro = readme ? file_body(readme) : "# #{context.config.title}"
12
+ document([intro, api(from), files(from)])
13
+ end
14
+
15
+ # @param file [ExtraFile]
16
+ # @return [String] a stitched file with its links and references resolved
17
+ def render_file(file)
18
+ document([file_body(file)])
19
+ end
20
+
21
+ private
22
+
23
+ def file_body(file)
24
+ linker.text(context.stitcher.relocated_content(file), object: registry.root, from: file.output, assets: false)
25
+ end
26
+
27
+ def api(from)
28
+ lines = tree(registry.root, from, 0)
29
+ top = NamespacePage.new(context, registry.root)
30
+ lines << "- [Top level](#{path_map.relative(PathMap::TOP_LEVEL, from)})" if top.content?
31
+ ['## API reference', lines.join("\n")] unless lines.empty?
32
+ end
33
+
34
+ def tree(namespace, from, depth)
35
+ namespace.namespaces.select { |child| filter.visible?(child) }.flat_map do |child|
36
+ entry = bullet(linker.link(child, from: from), summary(child, from))
37
+ ["#{' ' * depth}#{entry}", *tree(child, from, depth + 1)]
38
+ end
39
+ end
40
+
41
+ def files(from)
42
+ extras = context.stitcher.files.reject(&:readme)
43
+ return nil if extras.empty?
44
+
45
+ ['## Files', extras.map { |file| "- [#{file.title}](#{path_map.relative(file.output, from)})" }.join("\n")]
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boondoc
4
+ module Renderer
5
+ # Renders the detail section for one method or one attribute.
6
+ class MethodSection < Base
7
+ # @param context [Context]
8
+ # @param from [String] the output file being written
9
+ def initialize(context, from)
10
+ super(context)
11
+ @from = from
12
+ end
13
+
14
+ # @param meth [Model::MethodObject]
15
+ # @return [Array<String>] markdown blocks
16
+ def render(meth)
17
+ [
18
+ heading(meth.display_name, [meth]),
19
+ ruby(signature(meth)),
20
+ facts(meth),
21
+ *Docstring.new(context, meth, @from).render(meth.docstring)
22
+ ]
23
+ end
24
+
25
+ # Renders a reader and/or writer as a single attribute entry.
26
+ #
27
+ # @param accessors [Array<Model::MethodObject>] the reader, the writer, or both
28
+ # @return [Array<String>] markdown blocks
29
+ def render_attribute(accessors)
30
+ reader = accessors.find { |meth| meth.attribute == :reader }
31
+ writer = accessors.find { |meth| meth.attribute == :writer }
32
+ primary = reader || writer
33
+ name = primary.name.delete_suffix('=')
34
+ macro = reader && writer ? 'attr_accessor' : "attr_#{primary.attribute}"
35
+ [
36
+ heading("#{primary.separator}#{name}", accessors),
37
+ ruby("#{macro} :#{name}"),
38
+ facts(primary),
39
+ *Docstring.new(context, primary, @from).render(documented(accessors).docstring)
40
+ ]
41
+ end
42
+
43
+ # @param meth [Model::MethodObject]
44
+ # @return [String] the method as it would be defined, such as `def self.build(name)`
45
+ def signature(meth)
46
+ params = meth.parameters ? "(#{meth.parameters})" : ''
47
+ "def #{'self.' if meth.scope == :class}#{meth.name}#{params}"
48
+ end
49
+
50
+ private
51
+
52
+ def documented(accessors)
53
+ accessors.find { |meth| !meth.docstring.empty? } || accessors.first
54
+ end
55
+
56
+ # Explicit anchors, because heading slugs cannot tell `foo`, `foo?`, and `foo!` apart.
57
+ def heading(title, objects)
58
+ anchors = objects.map { |object| %(<a id="#{path_map.anchor_for(object)}"></a>) }.join
59
+ "### #{anchors}#{code(title)}"
60
+ end
61
+
62
+ def facts(meth)
63
+ lines = []
64
+ lines << "- **Visibility:** #{meth.visibility}" unless meth.visibility == :public
65
+ lines << "- **Alias for:** #{alias_link(meth)}" if meth.alias_for
66
+ aliases = aliases_of(meth)
67
+ lines << "- **Also known as:** #{aliases.join(', ')}" unless aliases.empty?
68
+ lines.join("\n") unless lines.empty?
69
+ end
70
+
71
+ def alias_link(meth)
72
+ original = registry[Model::MethodObject.path_for(meth.namespace, meth.scope, meth.alias_for)]
73
+ label = code("#{meth.separator}#{meth.alias_for}")
74
+ original ? linker.link(original, from: @from, label: label) : label
75
+ end
76
+
77
+ def aliases_of(meth)
78
+ aliases = context.aliases(meth.namespace, meth.scope).fetch(meth.name, [])
79
+ aliases.select { |other| filter.visible?(other) }
80
+ .map { |other| linker.link(other, from: @from, label: code(other.display_name)) }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boondoc
4
+ module Renderer
5
+ # Renders the page for one class or module, or for the top-level namespace.
6
+ class NamespacePage < Base # rubocop:disable Metrics/ClassLength
7
+ SCOPES = { class: 'Class methods', instance: 'Instance methods' }.freeze
8
+ MIXINS = { include: 'Includes', extend: 'Extended by', prepend: 'Prepended by' }.freeze
9
+ MAX_CONSTANT_LINES = 12
10
+ # A guard against a superclass chain that never ends; real chains are far shorter.
11
+ MAX_SUPERCLASS_CHAIN = 20
12
+
13
+ # @param context [Context]
14
+ # @param namespace [Model::NamespaceObject]
15
+ def initialize(context, namespace)
16
+ super(context)
17
+ @namespace = namespace
18
+ @from = path_map.file_for(namespace)
19
+ @methods = MethodSection.new(context, @from)
20
+ end
21
+
22
+ # @return [Boolean] false when there is nothing worth writing a page for
23
+ def content?
24
+ !@namespace.root? || visible(@namespace.children.grep_v(Model::NamespaceObject)).any?
25
+ end
26
+
27
+ # @return [String] the whole page
28
+ def render
29
+ document([
30
+ "# #{title}", facts, Docstring.new(context, @namespace, @from).render(@namespace.docstring),
31
+ namespaces, constants, attributes,
32
+ SCOPES.keys.map { |scope| method_sections(scope) }, inherited_methods
33
+ ])
34
+ end
35
+
36
+ private
37
+
38
+ def visible(objects)
39
+ objects.select { |object| filter.visible?(object) }
40
+ end
41
+
42
+ def title
43
+ return 'Top level' if @namespace.root?
44
+
45
+ "#{@namespace.kind.to_s.capitalize}: #{@namespace.path}"
46
+ end
47
+
48
+ # --- header -------------------------------------------------------------
49
+
50
+ def facts
51
+ lines = []
52
+ lines << "- **Inherits:** #{superclass_chain.join(' < ')}" if @namespace.superclass
53
+ lines.concat(mixin_facts)
54
+ lines << "- **Defined in:** #{source_links.join(', ')}" unless @namespace.files.empty?
55
+ lines.join("\n") unless lines.empty?
56
+ end
57
+
58
+ def mixin_facts
59
+ MIXINS.filter_map do |kind, label|
60
+ names = @namespace.mixins[kind]
61
+ "- **#{label}:** #{names.map { |name| constant_link(name) }.join(', ')}" unless names.empty?
62
+ end
63
+ end
64
+
65
+ def constant_link(name)
66
+ found = registry.resolve(@namespace, name)
67
+ found.is_a?(Model::NamespaceObject) ? linker.link(found, from: @from) : code(name)
68
+ end
69
+
70
+ def superclass_chain
71
+ chain = []
72
+ current = @namespace
73
+ while current&.superclass && chain.size < MAX_SUPERCLASS_CHAIN
74
+ found = registry.superclass(current)
75
+ chain << (found ? linker.link(found, from: @from) : code(current.superclass))
76
+ current = found
77
+ break if current.equal?(@namespace)
78
+ end
79
+ chain
80
+ end
81
+
82
+ def source_links
83
+ @namespace.files.map { |file, _| "[#{code(file)}](#{path_map.source_href(file, from: @from)})" }
84
+ end
85
+
86
+ # --- sections -----------------------------------------------------------
87
+
88
+ def namespaces
89
+ children = visible(@namespace.namespaces)
90
+ return nil if children.empty?
91
+
92
+ ['## Namespaces', children.map { |child| bullet(linker.link(child, from: @from), summary(child, @from)) }
93
+ .join("\n")]
94
+ end
95
+
96
+ def constants
97
+ constants = visible(@namespace.constants)
98
+ return nil if constants.empty?
99
+
100
+ ['## Constants', *constants.flat_map { |constant| constant_section(constant) }]
101
+ end
102
+
103
+ def constant_section(constant)
104
+ [
105
+ %(### <a id="#{path_map.anchor_for(constant)}"></a>#{code(constant.name)}),
106
+ ruby("#{constant.name} = #{truncate(constant.value)}"),
107
+ *Docstring.new(context, constant, @from).render(constant.docstring)
108
+ ]
109
+ end
110
+
111
+ def truncate(value)
112
+ lines = value.lines
113
+ return value if lines.size <= MAX_CONSTANT_LINES
114
+
115
+ "#{lines.first(MAX_CONSTANT_LINES).join.rstrip}\n# ... (#{lines.size - MAX_CONSTANT_LINES} more lines)"
116
+ end
117
+
118
+ def attributes
119
+ groups = SCOPES.keys.flat_map do |scope|
120
+ visible(context.meths(@namespace, scope)).select(&:attribute)
121
+ .group_by { |meth| meth.name.delete_suffix('=') }.values
122
+ end
123
+ return nil if groups.empty?
124
+
125
+ ['## Attributes', *groups.flat_map { |accessors| @methods.render_attribute(accessors) }]
126
+ end
127
+
128
+ def method_sections(scope)
129
+ meths = visible(context.meths(@namespace, scope)).reject(&:attribute)
130
+ return nil if meths.empty?
131
+
132
+ ["## #{SCOPES[scope]}", summary_table(meths), *meths.flat_map { |meth| @methods.render(meth) }]
133
+ end
134
+
135
+ def summary_table(meths)
136
+ rows = meths.map do |meth|
137
+ label = Markdown.table_cell(@methods.signature(meth).delete_prefix('def ').delete_prefix('self.'))
138
+ "| [#{code("#{meth.separator}#{label}")}](#{path_map.href(meth, from: @from)}) | #{summary(meth, @from)} |"
139
+ end
140
+ ['| Method | Summary |', '| --- | --- |', *rows].join("\n")
141
+ end
142
+
143
+ # Each method is listed under the nearest ancestor that defines it, the
144
+ # one Ruby would actually call.
145
+ def inherited_methods
146
+ seen = context.meths(@namespace, :instance).to_set(&:name)
147
+ items = registry.ancestors(@namespace).drop(1).filter_map { |ancestor| inherited_from_ancestor(ancestor, seen) }
148
+ ['## Inherited methods', items.join("\n")] unless items.empty?
149
+ end
150
+
151
+ def inherited_from_ancestor(ancestor, seen)
152
+ meths = context.meths(ancestor, :instance).reject { |meth| seen.include?(meth.name) }
153
+ seen.merge(meths.map(&:name))
154
+ inherited_from(ancestor, visible(meths)) if filter.visible?(ancestor)
155
+ end
156
+
157
+ def inherited_from(ancestor, meths)
158
+ return nil if meths.empty?
159
+
160
+ links = meths.map { |meth| linker.link(meth, from: @from, label: code(meth.display_name)) }
161
+ "- From #{linker.link(ancestor, from: @from)}: #{links.join(', ')}"
162
+ end
163
+ end
164
+ end
165
+ end