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,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boondoc
4
+ # Finds the paths in markdown links, images and HTML attributes so they can be rewritten.
5
+ #
6
+ # Each path is passed on together with whether it is embedded in the page, as an image, video,
7
+ # audio, `<source>` or `srcset`, or only linked to. Code blocks and code spans are left alone.
8
+ module Links
9
+ # Targets that are not paths in the project: URLs, anchors, absolute paths, and `{references}`.
10
+ EXTERNAL = %r{\A(?:[a-z][a-z0-9+.-]*:|[#/\{])}i
11
+ INLINE = /(?<lead>\]\(\s*<?)(?<target>[^)\s>]+)/ # [text](target) and ![alt](target)
12
+ DEFINITION = /^(?<lead> {0,3}\[(?<label>[^\]]+)\]:\s+<?)(?<target>[^\s>]+)/ # [label]: target
13
+ ATTRIBUTE = /(?<lead>\b(?<name>src|href|poster|data)=["'])(?<target>[^"']+)/ # <img src="target">
14
+ SRCSET = /(?<lead>\bsrcset=["'])(?<value>[^"']+)/ # <source srcset="a.png 1x, b.png 2x">
15
+ SRCSET_ENTRY = /(?<url>[^\s,]+)(?<descriptor>\s+\d[\d.]*[wx])?/
16
+ IMAGE_REFERENCE = /!\[(?<alt>[^\]]*)\]\[(?<label>[^\]]*)\]/
17
+ EMBEDDING_ATTRIBUTES = %w[src poster data].freeze
18
+ IMAGE_OPENING = /!\[(?:[^\[\]]|\[[^\[\]]*\])*\z/ # an image whose alt text is still open
19
+
20
+ module_function
21
+
22
+ # @param content [String] markdown
23
+ # @yieldparam target [String] a path as written, with any fragment or query still attached
24
+ # @yieldparam embedded [Boolean] whether the page embeds the file rather than linking to it
25
+ # @yieldreturn [String] the path to write in its place
26
+ # @return [String] the content with every path replaced by the block's result
27
+ def rewrite(content, &)
28
+ labels = embedded_labels(content)
29
+ Markdown.map_prose(content) { |prose| rewrite_prose(prose, labels, &) }
30
+ end
31
+
32
+ # @api private
33
+ def rewrite_prose(prose, labels, &)
34
+ prose = rewrite_links(prose, labels, &)
35
+ prose = prose.gsub(ATTRIBUTE) do
36
+ replace(Regexp.last_match, EMBEDDING_ATTRIBUTES.include?(Regexp.last_match[:name]), &)
37
+ end
38
+ prose.gsub(SRCSET) { "#{Regexp.last_match[:lead]}#{rewrite_srcset(Regexp.last_match[:value], &)}" }
39
+ end
40
+
41
+ # @api private
42
+ def rewrite_links(prose, labels, &)
43
+ inline = prose.gsub(INLINE) { replace(Regexp.last_match, embedded_inline?(prose, Regexp.last_match), &) }
44
+ inline.gsub(DEFINITION) { replace(Regexp.last_match, labels.include?(Regexp.last_match[:label].downcase), &) }
45
+ end
46
+
47
+ # @api private
48
+ def replace(match, embedded)
49
+ return match[0] if EXTERNAL.match?(match[:target])
50
+
51
+ "#{match[:lead]}#{yield(match[:target], embedded)}"
52
+ end
53
+
54
+ # @api private
55
+ def rewrite_srcset(value)
56
+ value.gsub(SRCSET_ENTRY) do
57
+ url = Regexp.last_match[:url]
58
+ "#{EXTERNAL.match?(url) ? url : yield(url, true)}#{Regexp.last_match[:descriptor]}"
59
+ end
60
+ end
61
+
62
+ # The labels that an `![alt][label]` image refers to.
63
+ #
64
+ # @api private
65
+ def embedded_labels(content)
66
+ content.scan(IMAGE_REFERENCE).map { |alt, label| (label.empty? ? alt : label).downcase }
67
+ end
68
+
69
+ # Whether the link at `match` is the text of an image, `![...](...)`, rather than a plain link.
70
+ # The text before it, on the same line, must open an image and close only nested brackets.
71
+ #
72
+ # @api private
73
+ def embedded_inline?(text, match)
74
+ line = (text.rindex("\n", match.begin(:lead)) || -1) + 1
75
+ IMAGE_OPENING.match?(text[line...match.begin(:lead)])
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boondoc
4
+ # Just enough markdown awareness to leave code alone while rewriting prose.
5
+ module Markdown
6
+ FENCE = /\A {0,3}(`{3,}|~{3,})/
7
+ INDENTED = /\A(?: {4}|\t)\s*\S/
8
+ LIST_ITEM = /\A\s*(?:[-*+]|\d+[.)])\s/
9
+ INLINE_CODE = /(?<!`)(`+)(?!`)(?:(?!\n\s*\n).)+?(?<!`)\1(?!`)/m
10
+
11
+ module_function
12
+
13
+ # Rewrites the prose of a markdown document, skipping fenced code blocks,
14
+ # indented code blocks and inline code spans.
15
+ #
16
+ # @param text [String]
17
+ # @yieldparam prose [String] a run of text outside any code
18
+ # @yieldreturn [String] its replacement
19
+ # @return [String]
20
+ def map_prose(text, &)
21
+ state = { fence: nil, indented: false, blank: true, list: false }
22
+ output = +''
23
+ prose = +''
24
+ text.each_line do |line|
25
+ next prose << line unless code_line?(line, state)
26
+
27
+ output << map_inline(prose, &) << line
28
+ prose = +''
29
+ end
30
+ output << map_inline(prose, &)
31
+ end
32
+
33
+ # @param text [String] a single table cell
34
+ # @return [String] the text on one line with pipes escaped
35
+ def table_cell(text)
36
+ text.gsub(/\s*\n\s*/, ' ').gsub('|', '\\|').strip
37
+ end
38
+
39
+ # @api private
40
+ def code_line?(line, state)
41
+ return fence_line?(line, state) if state[:fence] || FENCE.match?(line)
42
+
43
+ blank = line.strip.empty?
44
+ code = !blank && INDENTED.match?(line) && indented_code_allowed?(state)
45
+ state[:indented] = code || (blank && state[:indented])
46
+ state[:list] = in_list?(line, blank, state)
47
+ state[:blank] = blank
48
+ code
49
+ end
50
+
51
+ # An indented line is code when it continues a code block, or starts one
52
+ # after a blank line outside a list (where indentation means nesting).
53
+ #
54
+ # @api private
55
+ def indented_code_allowed?(state)
56
+ state[:indented] || (state[:blank] && !state[:list])
57
+ end
58
+
59
+ # @api private
60
+ def in_list?(line, blank, state)
61
+ LIST_ITEM.match?(line) || (state[:list] && (blank || line.start_with?(' ', "\t")))
62
+ end
63
+
64
+ # @api private
65
+ def fence_line?(line, state)
66
+ if state[:fence]
67
+ closing = /\A {0,3}#{Regexp.escape(state[:fence][0])}{#{state[:fence].size},}\s*\z/
68
+ state[:fence] = nil if closing.match?(line)
69
+ else
70
+ state[:fence] = FENCE.match(line)[1]
71
+ end
72
+ state[:blank] = false
73
+ true
74
+ end
75
+
76
+ # @api private
77
+ def map_inline(prose)
78
+ return prose if prose.empty?
79
+
80
+ output = +''
81
+ position = 0
82
+ prose.scan(INLINE_CODE) do
83
+ match = Regexp.last_match
84
+ output << yield(prose[position...match.begin(0)]) << match[0]
85
+ position = match.end(0)
86
+ end
87
+ output << yield(prose[position..])
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boondoc
4
+ module Model
5
+ # Base class for everything that can be documented.
6
+ class CodeObject
7
+ # @return [String] the unqualified name
8
+ attr_reader :name
9
+ # @return [NamespaceObject, nil] the enclosing namespace (nil only for the root)
10
+ attr_reader :namespace
11
+ # @return [Docstring]
12
+ attr_accessor :docstring
13
+ # @return [Symbol] `:public`, `:protected`, or `:private`
14
+ attr_accessor :visibility
15
+ # @return [Array<Array(String, Integer)>] `[file, line]` pairs where the object is defined
16
+ attr_reader :files
17
+
18
+ # @param name [String]
19
+ # @param namespace [NamespaceObject, nil]
20
+ def initialize(name, namespace)
21
+ @name = name
22
+ @namespace = namespace
23
+ @docstring = Docstring.new
24
+ @visibility = :public
25
+ @files = []
26
+ end
27
+
28
+ # @return [String] the fully qualified path, such as `Foo::Bar#baz`
29
+ def path
30
+ namespace.nil? || namespace.root? ? name : "#{namespace.path}::#{name}"
31
+ end
32
+
33
+ # @return [Boolean]
34
+ def root?
35
+ false
36
+ end
37
+
38
+ # Records a definition site, ignoring duplicates.
39
+ #
40
+ # @param file [String]
41
+ # @param line [Integer]
42
+ # @return [void]
43
+ def add_file(file, line)
44
+ files << [file, line] unless files.any? { |existing, _| existing == file }
45
+ end
46
+
47
+ # @return [Boolean] true when tagged `@private` or `@api private`
48
+ def private_api?
49
+ docstring.tag?('private') || docstring.tags_named('api').any? { |tag| tag.text.strip == 'private' }
50
+ end
51
+ end
52
+
53
+ # A class, a module, or the top-level namespace.
54
+ class NamespaceObject < CodeObject
55
+ # @return [Symbol] `:class`, `:module`, or `:root`
56
+ attr_accessor :kind
57
+ # @return [String, nil] the superclass as written in source
58
+ attr_accessor :superclass
59
+ # @return [Hash{Symbol => Array<String>}] mixin names keyed by `:include`, `:extend`, `:prepend`
60
+ attr_reader :mixins
61
+ # @return [Array<CodeObject>] everything defined directly inside this namespace
62
+ attr_reader :children
63
+
64
+ # @param name [String]
65
+ # @param namespace [NamespaceObject, nil]
66
+ # @param kind [Symbol]
67
+ def initialize(name, namespace, kind: :module)
68
+ super(name, namespace)
69
+ @kind = kind
70
+ @mixins = { include: [], extend: [], prepend: [] }
71
+ @children = []
72
+ end
73
+
74
+ # @return [String] empty for the root
75
+ def path
76
+ root? ? '' : super
77
+ end
78
+
79
+ # @return [Boolean]
80
+ def root?
81
+ kind == :root
82
+ end
83
+
84
+ # @return [Array<NamespaceObject>] nested classes and modules, sorted by name
85
+ def namespaces
86
+ children.grep(NamespaceObject).sort_by(&:name)
87
+ end
88
+
89
+ # @return [Array<ConstantObject>] constants, sorted by name
90
+ def constants
91
+ children.grep(ConstantObject).sort_by(&:name)
92
+ end
93
+
94
+ # @param scope [Symbol] `:instance` or `:class`
95
+ # @return [Array<MethodObject>] methods in that scope, `initialize` first, then by name
96
+ def meths(scope)
97
+ children.grep(MethodObject).select { |meth| meth.scope == scope }
98
+ .sort_by { |meth| [meth.name == 'initialize' ? 0 : 1, meth.name] }
99
+ end
100
+ end
101
+
102
+ # A method or attribute.
103
+ class MethodObject < CodeObject
104
+ # @return [Symbol] `:instance` or `:class`
105
+ attr_reader :scope
106
+ # @return [String, nil] the parameter list as written, without parentheses
107
+ attr_accessor :parameters
108
+ # @return [Symbol, nil] `:reader` or `:writer` when defined by `attr_*`
109
+ attr_reader :attribute
110
+ # @return [String, nil] name of the method this one aliases
111
+ attr_reader :alias_for
112
+
113
+ # @param name [String]
114
+ # @param namespace [NamespaceObject]
115
+ # @param scope [Symbol]
116
+ # @param parameters [String, nil]
117
+ # @param attribute [Symbol, nil]
118
+ # @param alias_for [String, nil]
119
+ def initialize(name, namespace, scope: :instance, parameters: nil, attribute: nil, alias_for: nil)
120
+ super(name, namespace)
121
+ @scope = scope
122
+ @parameters = parameters
123
+ @attribute = attribute
124
+ @alias_for = alias_for
125
+ end
126
+
127
+ # @param namespace [NamespaceObject]
128
+ # @param scope [Symbol] `:instance` or `:class`
129
+ # @param name [String]
130
+ # @return [String] the path a method with that name and scope would have
131
+ def self.path_for(namespace, scope, name)
132
+ "#{namespace.path}#{scope == :class ? '.' : '#'}#{name}"
133
+ end
134
+
135
+ # @return [String] `#` for instance methods, `.` for class methods
136
+ def separator
137
+ scope == :class ? '.' : '#'
138
+ end
139
+
140
+ # @return [String]
141
+ def path
142
+ self.class.path_for(namespace, scope, name)
143
+ end
144
+
145
+ # @return [String] the name with its separator, such as `#each`
146
+ def display_name
147
+ "#{separator}#{name}"
148
+ end
149
+ end
150
+
151
+ # A constant that is not a class or module.
152
+ class ConstantObject < CodeObject
153
+ # @return [String] the source of the assigned value
154
+ attr_reader :value
155
+
156
+ # @param name [String]
157
+ # @param namespace [NamespaceObject]
158
+ # @param value [String]
159
+ def initialize(name, namespace, value: '')
160
+ super(name, namespace)
161
+ @value = value
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boondoc
4
+ module Model
5
+ # One `@tag` from a docstring.
6
+ #
7
+ # Which fields are set depends on the tag: `@param` uses `name`, `types` and
8
+ # `text`; `@example` uses `name` as its title; `@option` adds `key` and
9
+ # `default`; `@overload` carries a `signature` and a nested `docstring`.
10
+ Tag = Struct.new(:tag_name, :name, :types, :text, :key, :default, :signature, :docstring,
11
+ keyword_init: true) do
12
+ def initialize(**)
13
+ super
14
+ self.text ||= ''
15
+ end
16
+ end
17
+
18
+ # A parsed documentation comment: free markdown text plus tags.
19
+ class Docstring
20
+ # @return [String] the markdown body, without tags
21
+ attr_reader :text
22
+ # @return [Array<Tag>]
23
+ attr_reader :tags
24
+ # @return [Array<String>] names of YARD directives (`@!macro`, ...) found in the comment
25
+ attr_reader :directives
26
+
27
+ # @param text [String]
28
+ # @param tags [Array<Tag>]
29
+ # @param directives [Array<String>]
30
+ def initialize(text = '', tags = [], directives = [])
31
+ @text = text
32
+ @tags = tags
33
+ @directives = directives
34
+ end
35
+
36
+ # @param name [String] tag name without the `@`
37
+ # @return [Array<Tag>]
38
+ def tags_named(name)
39
+ tags.select { |tag| tag.tag_name == name }
40
+ end
41
+
42
+ # @param name [String]
43
+ # @return [Tag, nil] the first tag with that name
44
+ def tag(name)
45
+ tags.find { |tag| tag.tag_name == name }
46
+ end
47
+
48
+ # @param name [String]
49
+ # @return [Boolean]
50
+ def tag?(name)
51
+ !tag(name).nil?
52
+ end
53
+
54
+ # @return [Boolean] true when there is neither text nor tags
55
+ def empty?
56
+ text.strip.empty? && tags.empty?
57
+ end
58
+
59
+ # @return [String] the first sentence of the first paragraph, on one line;
60
+ # falls back to the `@return` description when there is no body
61
+ def summary
62
+ paragraph = text.strip.split(/\n\s*\n/).first.to_s.gsub(/\s*\n\s*/, ' ')
63
+ return paragraph[/\A.*?[.!?](?=\s|\z)/] || paragraph unless paragraph.empty?
64
+
65
+ returned = tag('return')&.text.to_s.lines.first.to_s.strip
66
+ returned.empty? ? '' : "Returns #{returned}"
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boondoc
4
+ module Model
5
+ # Every documented object, keyed by path, with YARD-style reference lookup.
6
+ class Registry
7
+ include Enumerable
8
+
9
+ REFERENCE = /\A(?<const>(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*)?(?:(?<sep>#|\.|::)(?<meth>[^A-Z:].*))?\z/
10
+
11
+ # @return [NamespaceObject] the top-level namespace
12
+ attr_reader :root
13
+ # @return [Array<String>] `file:line` locations of comments that use YARD directives
14
+ attr_reader :directive_sites
15
+
16
+ def initialize
17
+ @root = NamespaceObject.new('', nil, kind: :root)
18
+ @objects = { '' => @root }
19
+ @directive_sites = []
20
+ end
21
+
22
+ # @return [Array<String>] the files that contain YARD directives, in the order they were parsed
23
+ def directive_files
24
+ @directive_sites.map { |site| site.rpartition(':').first }.uniq
25
+ end
26
+
27
+ # @param path [String]
28
+ # @return [CodeObject, nil]
29
+ def [](path)
30
+ @objects[path]
31
+ end
32
+
33
+ # Adds an object, replacing any existing object at the same path.
34
+ #
35
+ # @param object [CodeObject]
36
+ # @return [CodeObject] the object
37
+ def register(object)
38
+ delete(@objects[object.path])
39
+ @objects[object.path] = object
40
+ object.namespace.children << object
41
+ object
42
+ end
43
+
44
+ # @param object [CodeObject, nil]
45
+ # @return [void]
46
+ def delete(object)
47
+ return unless object
48
+
49
+ @objects.delete(object.path)
50
+ object.namespace&.children&.delete(object)
51
+ end
52
+
53
+ # @yieldparam object [CodeObject]
54
+ def each(&)
55
+ @objects.values.each(&)
56
+ end
57
+
58
+ # Resolves a reference such as `Foo::Bar`, `Foo#bar`, `#bar`, or `bar` as
59
+ # seen from `context`, searching enclosing namespaces and ancestors.
60
+ #
61
+ # @param context [CodeObject] where the reference was written
62
+ # @param reference [String]
63
+ # @return [CodeObject, nil]
64
+ def resolve(context, reference)
65
+ reference = reference.to_s.strip
66
+ match = REFERENCE.match(reference) || /\A(?<const>)(?<sep>)(?<meth>\S+)\z/.match(reference)
67
+ return nil if reference.empty? || match.nil?
68
+
69
+ start = context.is_a?(NamespaceObject) ? context : context.namespace
70
+ resolve_parts(start, match[:const].to_s, match[:sep].to_s, match[:meth])
71
+ end
72
+
73
+ # @param namespace [NamespaceObject]
74
+ # @return [Array<NamespaceObject>] the namespace followed by its documented
75
+ # mixins and superclasses, nearest first
76
+ def ancestors(namespace)
77
+ seen = [namespace]
78
+ seen.each do |current|
79
+ mixins = (current.mixins[:prepend] + current.mixins[:include]).map { |name| resolve_constant(current, name) }
80
+ [*mixins, superclass(current)].each do |found|
81
+ seen << found if found.is_a?(NamespaceObject) && !seen.include?(found)
82
+ end
83
+ end
84
+ seen
85
+ end
86
+
87
+ # Like Ruby, looks the superclass up from the scope around the class, and
88
+ # never finds the class itself, so `class Error < Error` inside a module
89
+ # refers to an outer `Error`.
90
+ #
91
+ # @param namespace [NamespaceObject]
92
+ # @return [NamespaceObject, nil] the documented superclass
93
+ def superclass(namespace)
94
+ return nil unless namespace.superclass && namespace.namespace
95
+
96
+ found = resolve_constant(namespace.namespace, namespace.superclass, except: namespace)
97
+ found if found.is_a?(NamespaceObject)
98
+ end
99
+
100
+ private
101
+
102
+ def resolve_parts(start, const, sep, meth)
103
+ return resolve_constant(start, const) if meth.nil?
104
+ return find_method(resolve_constant(start, const), sep, meth) unless const.empty?
105
+
106
+ lexical(start).each do |scope|
107
+ found = find_method(scope, sep, meth)
108
+ return found if found
109
+ end
110
+ nil
111
+ end
112
+
113
+ def resolve_constant(start, const, except: nil)
114
+ constant_candidates(start, const).each do |path|
115
+ found = self[path]
116
+ return found if found && !found.equal?(except)
117
+ end
118
+ nil
119
+ end
120
+
121
+ def constant_candidates(start, const)
122
+ return [const.delete_prefix('::')] if const.start_with?('::')
123
+
124
+ lexical(start).map { |scope| scope.root? ? const : "#{scope.path}::#{const}" }
125
+ end
126
+
127
+ def find_method(namespace, sep, meth)
128
+ return nil unless namespace.is_a?(NamespaceObject)
129
+
130
+ separators = { '' => %w[# .], '#' => ['#'] }.fetch(sep, ['.'])
131
+ ancestors(namespace).each do |ancestor|
132
+ separators.each do |separator|
133
+ found = self["#{ancestor.path}#{separator}#{meth}"]
134
+ return found if found
135
+ end
136
+ end
137
+ nil
138
+ end
139
+
140
+ def lexical(namespace)
141
+ scopes = []
142
+ while namespace
143
+ scopes << namespace
144
+ namespace = namespace.namespace
145
+ end
146
+ scopes
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Boondoc
6
+ # Maps objects to output files and anchors, and computes relative links.
7
+ class PathMap
8
+ TOP_LEVEL = 'top-level.md'
9
+
10
+ # @param config [Config]
11
+ # @param registry [Model::Registry] consulted for classes and modules whose
12
+ # names differ only by case
13
+ def initialize(config, registry)
14
+ @config = config
15
+ @registry = registry
16
+ @relative = {}
17
+ end
18
+
19
+ # @param object [Model::CodeObject]
20
+ # @return [String] output file, relative to the output directory. Classes and
21
+ # modules whose names differ only by case get their kind appended, such as
22
+ # `CLI.class.md` and `Cli.module.md`, so that the files stay apart on
23
+ # case-insensitive filesystems.
24
+ def file_for(object)
25
+ namespace = object.is_a?(Model::NamespaceObject) ? object : object.namespace
26
+ namespace.root? ? TOP_LEVEL : (@files ||= {}.compare_by_identity)[namespace] ||= "#{directory(namespace)}.md"
27
+ end
28
+
29
+ # @param object [Model::CodeObject]
30
+ # @return [String, nil] the anchor inside its file; nil for namespaces
31
+ def anchor_for(object)
32
+ case object
33
+ when Model::MethodObject then "method-#{object.scope == :class ? 'c' : 'i'}-#{escape(object.name)}"
34
+ when Model::ConstantObject then "constant-#{escape(object.name)}"
35
+ end
36
+ end
37
+
38
+ # @param object [Model::CodeObject]
39
+ # @param from [String] the output file the link is written in
40
+ # @return [String] a relative link to the object
41
+ def href(object, from:)
42
+ anchor = anchor_for(object)
43
+ file = file_for(object)
44
+ return "##{anchor}" if anchor && file == from
45
+
46
+ [relative(file, from), anchor].compact.join('#')
47
+ end
48
+
49
+ # @param file [String] output file to link to
50
+ # @param from [String] output file the link is written in
51
+ # @return [String] frozen; each distinct link is worked out once, because
52
+ # the same few targets are linked many times in a run
53
+ def relative(file, from)
54
+ @relative[[file, from]] ||= encode(Pathname.new(file).relative_path_from(Pathname.new(from).dirname).to_s).freeze
55
+ end
56
+
57
+ # @param source [String] a path relative to the project root
58
+ # @param from [String] output file the link is written in
59
+ # @return [String] a relative link from the output file to a project file
60
+ def source_href(source, from:)
61
+ target = Pathname.new(File.expand_path(source, @config.root))
62
+ origin = Pathname.new(File.expand_path(from, @config.output_dir)).dirname
63
+ encode(target.relative_path_from(origin).to_s)
64
+ end
65
+
66
+ private
67
+
68
+ def directory(namespace)
69
+ parts = []
70
+ while namespace && !namespace.root?
71
+ parts.unshift(segments[namespace])
72
+ namespace = namespace.namespace
73
+ end
74
+ parts.join('/')
75
+ end
76
+
77
+ # The file or directory name of every class and module, worked out once
78
+ # because nothing changes the registry while pages are rendered.
79
+ def segments
80
+ @segments ||= {}.compare_by_identity.tap { |names| name_children(@registry.root, names) }
81
+ end
82
+
83
+ def name_children(parent, names)
84
+ parent.namespaces.group_by { |child| child.name.downcase }.each_value do |group|
85
+ group.each { |child| names[child] = child.name } if group.one?
86
+ disambiguate(group, names) if group.size > 1
87
+ end
88
+ parent.namespaces.each { |child| name_children(child, names) }
89
+ end
90
+
91
+ # Appends the kind, and a number when two of the same kind still collide.
92
+ def disambiguate(group, names)
93
+ seen = Hash.new(0)
94
+ group.each do |child|
95
+ count = (seen[child.kind] += 1)
96
+ names[child] = ["#{child.name}.#{child.kind}", (count if count > 1)].compact.join('-')
97
+ end
98
+ end
99
+
100
+ def escape(name)
101
+ name.gsub(/[^A-Za-z0-9_]/) { |char| format('-%02X', char.ord) }
102
+ end
103
+
104
+ def encode(path)
105
+ path.gsub(' ', '%20')
106
+ end
107
+ end
108
+ end