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,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Boondoc
|
|
6
|
+
# Options for one run, merged from defaults, `.yardopts`, `.boondoc.yml`, and the command line.
|
|
7
|
+
class Config
|
|
8
|
+
FILE = '.boondoc.yml'
|
|
9
|
+
DEFAULTS = {
|
|
10
|
+
sources: ['lib/**/*.rb'], exclude: [], output: 'docs', index: 'README.md',
|
|
11
|
+
readme: nil, files: [], assets: [], copy_linked_assets: false, title: nil,
|
|
12
|
+
private: false, protected: false, yard_macros: false,
|
|
13
|
+
strict: false, force: false, quiet: false, allow_outside_output: false, yardopts: true
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
# What each option accepts: a type name from {CHECKS} per key.
|
|
17
|
+
TYPES = {
|
|
18
|
+
sources: :list, exclude: :list, files: :list, assets: :list, copy_linked_assets: :boolean,
|
|
19
|
+
output: :string, index: :string, readme: :file_or_false, title: :optional_string,
|
|
20
|
+
private: :boolean, protected: :boolean, yard_macros: :boolean, strict: :boolean,
|
|
21
|
+
force: :boolean, quiet: :boolean, allow_outside_output: :boolean, yardopts: :boolean
|
|
22
|
+
}.freeze
|
|
23
|
+
# A test and a description for each type name in {TYPES}.
|
|
24
|
+
CHECKS = {
|
|
25
|
+
list: [->(value) { value.is_a?(String) || (value.is_a?(Array) && value.all?(String)) },
|
|
26
|
+
'a string or a list of strings'],
|
|
27
|
+
string: [->(value) { value.is_a?(String) }, 'a string'],
|
|
28
|
+
optional_string: [->(value) { value.nil? || value.is_a?(String) }, 'a string'],
|
|
29
|
+
file_or_false: [->(value) { value.nil? || value == false || value.is_a?(String) }, 'a file name or false'],
|
|
30
|
+
boolean: [->(value) { [true, false].include?(value) }, 'true or false']
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
attr_reader(*DEFAULTS.keys)
|
|
34
|
+
# @return [String] absolute path of the project being documented
|
|
35
|
+
attr_reader :root
|
|
36
|
+
|
|
37
|
+
# Builds a config from `.yardopts` and `.boondoc.yml` (if present) with `overrides` on top.
|
|
38
|
+
# Each option comes from the highest of those that sets it; lists are replaced, not merged.
|
|
39
|
+
# `.yardopts` is skipped when `yardopts` is false in the file or the overrides.
|
|
40
|
+
#
|
|
41
|
+
# @param overrides [Hash{Symbol => Object}] options that win over the file;
|
|
42
|
+
# `:config` names an alternative config file
|
|
43
|
+
# @param root [String] project directory
|
|
44
|
+
# @return [Config]
|
|
45
|
+
# @raise [Error] when the config file is missing, has unknown keys, or tries
|
|
46
|
+
# to set `allow_outside_output`, which only the caller may do
|
|
47
|
+
def self.load(overrides = {}, root: Dir.pwd)
|
|
48
|
+
overrides = overrides.transform_keys(&:to_sym)
|
|
49
|
+
path = File.expand_path(overrides[:config] || FILE, root)
|
|
50
|
+
raise Error, "config file not found: #{overrides[:config]}" if overrides[:config] && !File.file?(path)
|
|
51
|
+
|
|
52
|
+
settings = read_file(path).merge(overrides.except(:config).compact)
|
|
53
|
+
yardopts = Yardopts.read(root) unless settings[:yardopts] == false
|
|
54
|
+
new(root: root, from_yardopts: yardopts, **with_yardopts(settings, yardopts))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param settings [Hash{Symbol => Object}] options from the config file and the caller
|
|
58
|
+
# @param yardopts [Yardopts, nil]
|
|
59
|
+
# @return [Hash{Symbol => Object}] `settings` over what `.yardopts` sets
|
|
60
|
+
def self.with_yardopts(settings, yardopts)
|
|
61
|
+
yardopts ? yardopts.settings.merge(settings) : settings
|
|
62
|
+
end
|
|
63
|
+
private_class_method :with_yardopts
|
|
64
|
+
|
|
65
|
+
# @param path [String] the config file, which may not exist
|
|
66
|
+
# @return [Hash{Symbol => Object}]
|
|
67
|
+
# @raise [Error] when the file sets `allow_outside_output`
|
|
68
|
+
def self.read_file(path)
|
|
69
|
+
return {} unless File.file?(path)
|
|
70
|
+
|
|
71
|
+
settings = YAML.safe_load_file(path, symbolize_names: true) || {}
|
|
72
|
+
return settings unless settings.key?(:allow_outside_output)
|
|
73
|
+
|
|
74
|
+
raise Error, 'allow_outside_output cannot be set in a config file, because the project being ' \
|
|
75
|
+
'documented is not trusted to choose it; pass --allow-outside-output on the command line'
|
|
76
|
+
end
|
|
77
|
+
private_class_method :read_file
|
|
78
|
+
|
|
79
|
+
# @param root [String] project directory
|
|
80
|
+
# @param from_yardopts [Yardopts, nil] the project's `.yardopts`, whose `--exclude`
|
|
81
|
+
# patterns apply unless `exclude` is given and whose skipped options are reported
|
|
82
|
+
# by {#yardopts_notes}; its other settings are expected in `options` already
|
|
83
|
+
# @param options [Hash{Symbol => Object}] any of the keys in {DEFAULTS}
|
|
84
|
+
# @raise [Error] on unknown options, or an option of the wrong type
|
|
85
|
+
def initialize(root: Dir.pwd, from_yardopts: nil, **options)
|
|
86
|
+
unknown = options.keys - DEFAULTS.keys
|
|
87
|
+
raise Error, "unknown option#{'s' if unknown.size > 1}: #{unknown.join(', ')}" if unknown.any?
|
|
88
|
+
|
|
89
|
+
@root = File.expand_path(root)
|
|
90
|
+
@yardopts_file = from_yardopts
|
|
91
|
+
@yardopts_exclude_patterns = options.key?(:exclude) ? [] : from_yardopts&.exclude || []
|
|
92
|
+
DEFAULTS.merge(options).each do |key, value|
|
|
93
|
+
check_type(key, value)
|
|
94
|
+
instance_variable_set(:"@#{key}", value)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @return [String] absolute path of the output directory
|
|
99
|
+
# @raise [Error] when it is outside the project and `allow_outside_output`
|
|
100
|
+
# is not set, or when it is reached through a symlink inside the project
|
|
101
|
+
def output_dir
|
|
102
|
+
@output_dir ||= begin
|
|
103
|
+
path = File.expand_path(output, root)
|
|
104
|
+
check_output_inside(path) unless allow_outside_output
|
|
105
|
+
link = Containment.symlink_between(path, root)
|
|
106
|
+
raise Error, "output directory #{path} goes through the symlink #{link}" if link
|
|
107
|
+
|
|
108
|
+
path
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @return [Array<String>] what the run ignores from `.yardopts`, such as its `--output-dir`
|
|
113
|
+
def yardopts_notes
|
|
114
|
+
@yardopts_file ? @yardopts_file.notes(output: output, root: root) : []
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @return [String] the documentation title; defaults to the project directory name
|
|
118
|
+
def title
|
|
119
|
+
@title || File.basename(root)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [Array<String>] Ruby files to parse, relative to {#root}; files
|
|
123
|
+
# that resolve outside the project are left out, see {#outside_sources}
|
|
124
|
+
def source_files
|
|
125
|
+
source_split.first
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @return [Array<String>] sources that resolve outside the project, through an
|
|
129
|
+
# absolute path, `../`, or a symlink, and are therefore not parsed
|
|
130
|
+
def outside_sources
|
|
131
|
+
source_split.last
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @return [String, nil] the README to use as the index page, relative to {#root}
|
|
135
|
+
# @raise [Error] when an explicitly named README does not exist or is outside the project
|
|
136
|
+
def readme_file
|
|
137
|
+
return nil if readme == false
|
|
138
|
+
return inside_project('README.md') if readme.nil? && File.file?(File.join(root, 'README.md'))
|
|
139
|
+
return nil if readme.nil?
|
|
140
|
+
raise Error, "readme not found: #{readme}" unless File.file?(File.expand_path(readme, root))
|
|
141
|
+
|
|
142
|
+
inside_project(relative(readme))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [Array<String>] extra files to stitch in, relative to {#root}
|
|
146
|
+
# @raise [Error] when a file is outside the project
|
|
147
|
+
def extra_files
|
|
148
|
+
(expand(files) - [readme_file]).each { |path| inside_project(path) }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def check_type(key, value)
|
|
154
|
+
test, description = CHECKS.fetch(TYPES.fetch(key))
|
|
155
|
+
return if test.call(value)
|
|
156
|
+
|
|
157
|
+
raise Error, "option `#{key}` must be #{description} (got #{value.inspect})"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def source_split
|
|
161
|
+
@source_split ||= (expand(sources, ruby: true) - expand(exclude, ruby: true))
|
|
162
|
+
.reject { |path| excluded_by_yardopts?(path) }
|
|
163
|
+
.partition { |path| Containment.inside?(File.expand_path(path, root), root) }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def excluded_by_yardopts?(path)
|
|
167
|
+
@yardopts_exclude_patterns.any? { |pattern| pattern.match?(path) }
|
|
168
|
+
rescue Regexp::TimeoutError
|
|
169
|
+
raise Error, "an --exclude pattern in .yardopts took too long to match #{path}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def check_output_inside(path)
|
|
173
|
+
return if path == root || path.start_with?(File.join(root, ''))
|
|
174
|
+
|
|
175
|
+
raise Error, "output directory #{path} is outside the project (#{root}). A project must not choose where " \
|
|
176
|
+
'boondoc writes. To write there anyway, pass --allow-outside-output; that is a security ' \
|
|
177
|
+
'risk if you do not trust the project, and it is how docs are kept outside the repository.'
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def expand(patterns, ruby: false)
|
|
181
|
+
Array(patterns).flat_map do |pattern|
|
|
182
|
+
pattern = File.join(pattern, '**', '*.rb') if ruby && File.directory?(File.expand_path(pattern, root))
|
|
183
|
+
paths = Dir.glob(pattern, base: root).select { |path| File.file?(File.expand_path(path, root)) }
|
|
184
|
+
paths.map { |path| relative(path) }
|
|
185
|
+
end.uniq.sort
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Paths under the root become relative to it, with `..` segments resolved.
|
|
189
|
+
def relative(path)
|
|
190
|
+
absolute = File.expand_path(path, root)
|
|
191
|
+
absolute.start_with?(File.join(root, '')) ? absolute.delete_prefix(File.join(root, '')) : path
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Files that are copied into the output must not come from elsewhere on the machine.
|
|
195
|
+
def inside_project(path)
|
|
196
|
+
return path if Containment.inside?(File.expand_path(path, root), root)
|
|
197
|
+
|
|
198
|
+
raise Error, "#{path} is outside the project (#{root})"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boondoc
|
|
4
|
+
# Checks that a path stays inside a directory once symlinks are followed.
|
|
5
|
+
#
|
|
6
|
+
# The project being documented is not trusted to point Boondoc at files
|
|
7
|
+
# elsewhere on the machine, through `../`, absolute paths, or symlinks.
|
|
8
|
+
module Containment
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# @param path [String] an existing file or directory
|
|
12
|
+
# @param directory [String]
|
|
13
|
+
# @return [Boolean] true when the real path of `path` is `directory` or inside it
|
|
14
|
+
def inside?(path, directory)
|
|
15
|
+
real = File.realpath(path)
|
|
16
|
+
base = File.realpath(directory)
|
|
17
|
+
real == base || real.start_with?(File.join(base, ''))
|
|
18
|
+
rescue SystemCallError
|
|
19
|
+
false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Finds a symlink on the way from `directory` down to `path`.
|
|
23
|
+
#
|
|
24
|
+
# Only the names are looked at (`..` is resolved first), so this also works
|
|
25
|
+
# for a path that does not exist yet. `directory` itself is not checked.
|
|
26
|
+
#
|
|
27
|
+
# @param path [String] a path below `directory`, which need not exist
|
|
28
|
+
# @param directory [String]
|
|
29
|
+
# @return [String, nil] the first component that is a symlink, or nil when
|
|
30
|
+
# there is none or `path` is not below `directory`
|
|
31
|
+
def symlink_between(path, directory)
|
|
32
|
+
base = File.join(File.expand_path(directory), '')
|
|
33
|
+
full = File.expand_path(path)
|
|
34
|
+
return nil unless full.start_with?(base)
|
|
35
|
+
|
|
36
|
+
parts = full.delete_prefix(base).split('/')
|
|
37
|
+
parts.each_index.map { |count| File.join(base, *parts.first(count + 1)) }.find { |step| File.symlink?(step) }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boondoc
|
|
4
|
+
# Everything the renderers need to produce a page.
|
|
5
|
+
class Context
|
|
6
|
+
attr_reader :config, :registry, :reporter, :filter, :path_map, :assets, :stitcher, :linker
|
|
7
|
+
|
|
8
|
+
# @param config [Config]
|
|
9
|
+
# @param registry [Model::Registry]
|
|
10
|
+
# @param reporter [Reporter]
|
|
11
|
+
def initialize(config:, registry:, reporter:)
|
|
12
|
+
@config = config
|
|
13
|
+
@registry = registry
|
|
14
|
+
@reporter = reporter
|
|
15
|
+
@filter = Filter.new(config)
|
|
16
|
+
@path_map = PathMap.new(config, registry)
|
|
17
|
+
@assets = Assets.new(config)
|
|
18
|
+
@stitcher = Stitcher.new(config, assets: assets)
|
|
19
|
+
@linker = Linker.new(config: config, registry: registry, filter: filter, path_map: path_map,
|
|
20
|
+
stitcher: stitcher, assets: assets, reporter: reporter)
|
|
21
|
+
@meths = {}
|
|
22
|
+
@aliases = {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The methods of a namespace in one scope, sorted once per run.
|
|
26
|
+
#
|
|
27
|
+
# Nothing changes the registry while pages are rendered, so the list can be
|
|
28
|
+
# kept for the lifetime of the context. Keeping it here rather than in the
|
|
29
|
+
# model matters because the parser changes `children` directly.
|
|
30
|
+
#
|
|
31
|
+
# @param namespace [Model::NamespaceObject]
|
|
32
|
+
# @param scope [Symbol] `:instance` or `:class`
|
|
33
|
+
# @return [Array<Model::MethodObject>] see {Model::NamespaceObject#meths}
|
|
34
|
+
def meths(namespace, scope)
|
|
35
|
+
@meths[[namespace, scope]] ||= namespace.meths(scope).freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The aliases of the methods of a namespace, found without scanning every
|
|
39
|
+
# method for each one.
|
|
40
|
+
#
|
|
41
|
+
# @param namespace [Model::NamespaceObject]
|
|
42
|
+
# @param scope [Symbol] `:instance` or `:class`
|
|
43
|
+
# @return [Hash{String => Array<Model::MethodObject>}] aliases keyed by the name they alias
|
|
44
|
+
def aliases(namespace, scope)
|
|
45
|
+
@aliases[[namespace, scope]] ||= meths(namespace, scope).select(&:alias_for).group_by(&:alias_for).freeze
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boondoc
|
|
4
|
+
# Top-level classes and modules of Ruby core and the standard library. A
|
|
5
|
+
# fixed list, because what happens to be loaded in boondoc's own process
|
|
6
|
+
# (Thor, Rake, Boondoc itself) says nothing about the project being documented.
|
|
7
|
+
CORE_CONSTANTS = %w[
|
|
8
|
+
ArgumentError Array BasicObject Binding Class ClosedQueueError Comparable Complex ConditionVariable Data
|
|
9
|
+
Dir EOFError Encoding EncodingError Enumerable Enumerator Errno Exception FalseClass Fiber FiberError File
|
|
10
|
+
FileTest Float FloatDomainError FrozenError GC Hash IO IOError IndexError Integer Interrupt Kernel KeyError
|
|
11
|
+
LoadError LocalJumpError Marshal MatchData Math Method Module Mutex NameError NilClass NoMatchingPatternError
|
|
12
|
+
NoMatchingPatternKeyError NoMemoryError NoMethodError NotImplementedError Numeric Object ObjectSpace Pathname
|
|
13
|
+
Proc Process Queue Ractor Random Range RangeError Rational Refinement Regexp RegexpError Ruby RubyVM
|
|
14
|
+
RuntimeError ScriptError SecurityError Set Signal SignalException SizedQueue StandardError StopIteration String
|
|
15
|
+
Struct Symbol SyntaxError SystemCallError SystemExit SystemStackError Thread ThreadError ThreadGroup Time
|
|
16
|
+
TracePoint TrueClass TypeError UnboundMethod UncaughtThrowError Warning ZeroDivisionError
|
|
17
|
+
Base64 Benchmark BigDecimal CGI CSV Coverage DRb Date DateTime Delegator Digest ERB Etc Fcntl Fiddle FileUtils
|
|
18
|
+
Find Forwardable GetoptLong IPAddr IPSocket JSON Logger Monitor MonitorMixin Net Observable Open3 OpenSSL
|
|
19
|
+
OpenStruct OptionParser PP PStore Psych Racc Readline Reline Resolv Ripper SecureRandom Shellwords
|
|
20
|
+
SimpleDelegator Singleton Socket StringIO StringScanner Syslog TCPServer TCPSocket Tempfile Timeout Tmpdir
|
|
21
|
+
TSort UDPSocket UNIXServer UNIXSocket URI WeakRef YAML Zlib
|
|
22
|
+
].freeze
|
|
23
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boondoc
|
|
4
|
+
# Turns the text of a documentation comment into a {Model::Docstring}.
|
|
5
|
+
#
|
|
6
|
+
# A line starting with `@name` begins a tag; indented lines that follow belong
|
|
7
|
+
# to it. Everything else is the markdown body.
|
|
8
|
+
class DocstringParser
|
|
9
|
+
TAG_LINE = /\A@(?<directive>!)?(?<name>[a-z_][\w.]*)(?:\s+(?<rest>.*))?\z/i
|
|
10
|
+
NAMED = %w[param yieldparam attr attr_reader attr_writer].freeze
|
|
11
|
+
FENCE = /\A\s*(?:```|~~~)/
|
|
12
|
+
|
|
13
|
+
# @param text [String] comment text with the leading `#` already removed
|
|
14
|
+
# @return [Model::Docstring]
|
|
15
|
+
def self.parse(text)
|
|
16
|
+
new.parse(text)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param text [String]
|
|
20
|
+
# @return [Model::Docstring]
|
|
21
|
+
def parse(text)
|
|
22
|
+
@body = []
|
|
23
|
+
@tags = []
|
|
24
|
+
@directives = []
|
|
25
|
+
@current = nil
|
|
26
|
+
@fenced = false
|
|
27
|
+
text.to_s.each_line(chomp: true) { |line| consume(line) }
|
|
28
|
+
finish_tag
|
|
29
|
+
Model::Docstring.new(@body.join("\n").strip, @tags, @directives)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def consume(line)
|
|
35
|
+
match = TAG_LINE.match(line) unless @fenced
|
|
36
|
+
return start_tag(match) if match
|
|
37
|
+
return @current[:lines] << line if @current && continuation?(line)
|
|
38
|
+
|
|
39
|
+
finish_tag
|
|
40
|
+
@fenced = !@fenced if FENCE.match?(line)
|
|
41
|
+
@body << line
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def start_tag(match)
|
|
45
|
+
finish_tag
|
|
46
|
+
@current = { name: match[:name], directive: !match[:directive].nil?, lines: [match[:rest].to_s] }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def continuation?(line)
|
|
50
|
+
line.strip.empty? || line.start_with?(' ', "\t")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def finish_tag
|
|
54
|
+
return unless @current
|
|
55
|
+
|
|
56
|
+
current = @current
|
|
57
|
+
@current = nil
|
|
58
|
+
return @directives << current[:name] if current[:directive]
|
|
59
|
+
|
|
60
|
+
first, *rest = current[:lines]
|
|
61
|
+
@tags << build(current[:name], first.strip, dedent(rest))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build(name, first, rest)
|
|
65
|
+
case name
|
|
66
|
+
when 'example' then Model::Tag.new(tag_name: name, name: first, text: rest)
|
|
67
|
+
when 'overload' then overload(first, rest)
|
|
68
|
+
when 'option' then option(join(first, rest))
|
|
69
|
+
when 'see' then see(join(first, rest))
|
|
70
|
+
when *NAMED then named(name, join(first, rest))
|
|
71
|
+
else typed(name, join(first, rest))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def overload(signature, rest)
|
|
76
|
+
Model::Tag.new(tag_name: 'overload', signature: signature, docstring: self.class.parse(rest))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# `@param name [Types] text` or `@param [Types] name text`
|
|
80
|
+
def named(tag_name, text)
|
|
81
|
+
types, text = TypeParser.extract(text)
|
|
82
|
+
name, text = text.split(/\s+/, 2)
|
|
83
|
+
types, text = TypeParser.extract(text.to_s) unless types
|
|
84
|
+
Model::Tag.new(tag_name: tag_name, name: name, types: types, text: text.to_s.strip)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# `@return [Types] text`
|
|
88
|
+
def typed(tag_name, text)
|
|
89
|
+
types, text = TypeParser.extract(text)
|
|
90
|
+
Model::Tag.new(tag_name: tag_name, types: types, text: text.strip)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# `@option opts [Types] :key (default) text`
|
|
94
|
+
def option(text)
|
|
95
|
+
name, text = text.split(/\s+/, 2)
|
|
96
|
+
types, text = TypeParser.extract(text.to_s)
|
|
97
|
+
key, text = text.split(/\s+/, 2)
|
|
98
|
+
default = text.to_s[/\A\((.*?)\)\s*/, 1]
|
|
99
|
+
text = text.to_s.sub(/\A\(.*?\)\s*/, '') if default
|
|
100
|
+
Model::Tag.new(tag_name: 'option', name: name, types: types, key: key, default: default,
|
|
101
|
+
text: text.to_s.strip)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# `@see reference text`
|
|
105
|
+
def see(text)
|
|
106
|
+
name, text = text.split(/\s+/, 2)
|
|
107
|
+
Model::Tag.new(tag_name: 'see', name: name, text: text.to_s.strip)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def join(first, rest)
|
|
111
|
+
[first, rest].reject(&:empty?).join("\n")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def dedent(lines)
|
|
115
|
+
indent = lines.reject { |line| line.strip.empty? }.map { |line| line[/\A[ \t]*/].size }.min || 0
|
|
116
|
+
lines.map { |line| line[indent..] || '' }.join("\n").strip
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boondoc
|
|
4
|
+
# Decides which objects appear in the output.
|
|
5
|
+
class Filter
|
|
6
|
+
# @param config [Config]
|
|
7
|
+
def initialize(config)
|
|
8
|
+
@config = config
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# An object is visible when it and every enclosing namespace pass the
|
|
12
|
+
# visibility options (`--private`, `--protected`).
|
|
13
|
+
#
|
|
14
|
+
# @param object [Model::CodeObject]
|
|
15
|
+
# @return [Boolean]
|
|
16
|
+
def visible?(object)
|
|
17
|
+
return true if object.root?
|
|
18
|
+
|
|
19
|
+
allowed?(object) && visible?(object.namespace)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def allowed?(object)
|
|
25
|
+
return false if object.private_api? && !@config.private
|
|
26
|
+
|
|
27
|
+
case object.visibility
|
|
28
|
+
when :private then @config.private
|
|
29
|
+
when :protected then @config.private || @config.protected
|
|
30
|
+
else true
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boondoc
|
|
4
|
+
module Frontend
|
|
5
|
+
# Finds the comment block that documents a given line of a parsed file.
|
|
6
|
+
class Comments
|
|
7
|
+
IGNORED = /\A#(?:!|\s*(?:rubocop:|frozen_string_literal:|encoding:|typed:|:nodoc:))/
|
|
8
|
+
DIRECTIVE = /^\s*@!\w/
|
|
9
|
+
|
|
10
|
+
# @param result [::Prism::ParseResult]
|
|
11
|
+
def initialize(result)
|
|
12
|
+
@blocks = {}
|
|
13
|
+
@trailing = {}
|
|
14
|
+
group(result.comments.grep(::Prism::InlineComment))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param line [Integer] the line a definition starts on
|
|
18
|
+
# @return [String, nil] the text of the comment block ending on the line above
|
|
19
|
+
def docstring_for(line)
|
|
20
|
+
@blocks[line - 1]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param line [Integer]
|
|
24
|
+
# @return [Boolean] true when the line carries a trailing `# :nodoc:`
|
|
25
|
+
def nodoc?(line)
|
|
26
|
+
@trailing[line].to_s.include?(':nodoc:')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Array<Integer>] last lines of comment blocks that contain YARD directives
|
|
30
|
+
def directive_lines
|
|
31
|
+
@blocks.select { |_, text| DIRECTIVE.match?(text) }.keys
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def group(comments)
|
|
37
|
+
run = []
|
|
38
|
+
comments.each do |comment|
|
|
39
|
+
next @trailing[comment.location.start_line] = comment.location.slice if comment.trailing?
|
|
40
|
+
|
|
41
|
+
flush(run) unless adjacent?(run.last, comment)
|
|
42
|
+
run << comment
|
|
43
|
+
end
|
|
44
|
+
flush(run)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def adjacent?(previous, comment)
|
|
48
|
+
previous.nil? || (previous.location.start_line + 1 == comment.location.start_line &&
|
|
49
|
+
previous.location.start_column == comment.location.start_column)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def flush(run)
|
|
53
|
+
return if run.empty?
|
|
54
|
+
|
|
55
|
+
text = clean(run.map { |comment| comment.location.slice })
|
|
56
|
+
@blocks[run.last.location.start_line] = text unless text.strip.empty?
|
|
57
|
+
run.clear
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def clean(lines)
|
|
61
|
+
lines = lines.grep_v(IGNORED).map { |line| line.delete_prefix('#').rstrip }
|
|
62
|
+
indent = lines.reject(&:empty?).map { |line| line[/\A */].size }.min || 0
|
|
63
|
+
lines.map { |line| line[indent..] || '' }.join("\n")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boondoc
|
|
4
|
+
module Frontend
|
|
5
|
+
# Parses Ruby files with Prism and fills a {Model::Registry}.
|
|
6
|
+
class PrismParser
|
|
7
|
+
# @param registry [Model::Registry]
|
|
8
|
+
# @param reporter [Reporter]
|
|
9
|
+
def initialize(registry, reporter)
|
|
10
|
+
@registry = registry
|
|
11
|
+
@reporter = reporter
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @param path [String] file to parse
|
|
15
|
+
# @param label [String] the path to record on objects (usually relative to the project)
|
|
16
|
+
# @return [void]
|
|
17
|
+
def parse_file(path, label: path)
|
|
18
|
+
parse(File.read(path), label)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param source [String] Ruby source
|
|
22
|
+
# @param label [String] file name to record on objects
|
|
23
|
+
# @return [void]
|
|
24
|
+
def parse(source, label = '(source)')
|
|
25
|
+
result = ::Prism.parse(source)
|
|
26
|
+
return syntax_error(label, result) if result.failure?
|
|
27
|
+
|
|
28
|
+
comments = Comments.new(result)
|
|
29
|
+
result.value.accept(Visitor.new(@registry, label, comments))
|
|
30
|
+
comments.directive_lines.each { |line| @registry.directive_sites << "#{label}:#{line}" }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def syntax_error(label, result)
|
|
36
|
+
@reporter.warn("#{label}: skipped, syntax error: #{result.errors.first.message}")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|