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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +17 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +345 -0
- data/exe/boondoc +6 -0
- data/lib/boondoc/assets.rb +112 -0
- data/lib/boondoc/cli.rb +99 -0
- data/lib/boondoc/config.rb +201 -0
- data/lib/boondoc/containment.rb +40 -0
- data/lib/boondoc/context.rb +48 -0
- data/lib/boondoc/core_constants.rb +23 -0
- data/lib/boondoc/docstring_parser.rb +119 -0
- data/lib/boondoc/filter.rb +34 -0
- data/lib/boondoc/frontend/comments.rb +67 -0
- data/lib/boondoc/frontend/prism_parser.rb +40 -0
- data/lib/boondoc/frontend/visitor.rb +359 -0
- data/lib/boondoc/generator.rb +116 -0
- data/lib/boondoc/linker.rb +175 -0
- data/lib/boondoc/links.rb +78 -0
- data/lib/boondoc/markdown.rb +90 -0
- data/lib/boondoc/model/code_object.rb +165 -0
- data/lib/boondoc/model/docstring.rb +70 -0
- data/lib/boondoc/model/registry.rb +150 -0
- data/lib/boondoc/path_map.rb +108 -0
- data/lib/boondoc/preview.rb +81 -0
- data/lib/boondoc/rake_task.rb +39 -0
- data/lib/boondoc/references.rb +59 -0
- data/lib/boondoc/renderer/base.rb +50 -0
- data/lib/boondoc/renderer/docstring.rb +140 -0
- data/lib/boondoc/renderer/index.rb +49 -0
- data/lib/boondoc/renderer/method_section.rb +84 -0
- data/lib/boondoc/renderer/namespace_page.rb +165 -0
- data/lib/boondoc/reporter.rb +36 -0
- data/lib/boondoc/stitcher.rb +89 -0
- data/lib/boondoc/type_parser.rb +78 -0
- data/lib/boondoc/version.rb +5 -0
- data/lib/boondoc/writer.rb +163 -0
- data/lib/boondoc/yard_bridge.rb +113 -0
- data/lib/boondoc/yardopts.rb +165 -0
- data/lib/boondoc.rb +48 -0
- 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
|