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,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