rich-ri 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.
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "manual"
4
+
5
+ module RichRI
6
+ class CLI
7
+ def self.run(argv)
8
+ new.run(argv)
9
+ end
10
+
11
+ def run(argv)
12
+ if argv.first == "--complete"
13
+ Completion.new.write(argv.drop(1), $stdout)
14
+ return 0
15
+ end
16
+
17
+ options = Options.new.parse(argv)
18
+ return action(*options.action, options: options) if options.action
19
+
20
+ driver_options = options.driver_options
21
+ driver_options[:rich_ri_color] = color?(options.color)
22
+ driver_options[:rich_ri_theme] = options.theme
23
+ driver_options[:rich_ri_bat_theme] = options.bat_theme
24
+ driver_options[:rich_ri_shell_theme] = options.shell_theme
25
+ with_pager(options.pager_command) do
26
+ if driver_options[:dump_path]
27
+ dump(driver_options[:dump_path])
28
+ else
29
+ Driver.new(driver_options).run
30
+ end
31
+ end
32
+ 0
33
+ rescue Errno::EPIPE
34
+ 0
35
+ rescue OptionParser::ParseError, ArgumentError, RDoc::Error, TypeError, LoadError, SystemCallError => e
36
+ if (dependency = optional_dependency(e))
37
+ warn "rich-ri: --#{dependency == 'webrick' ? 'server' : 'profile'} requires the optional #{dependency} gem.\n" \
38
+ "Install it for your active Ruby: gem install #{dependency}"
39
+ elsif incompatible_cache?(e)
40
+ warn "rich-ri: incompatible RI cache format for this Ruby and RDoc.\n" \
41
+ "Regenerate the documentation with your current Ruby and RDoc. For gems: gem rdoc GEM_NAME --ri.\n" \
42
+ "For Ruby core documentation, see https://github.com/hvpaiva/rich-ri/blob/main/docs/troubleshooting.md"
43
+ else
44
+ warn "rich-ri: #{RichRI.sanitize(e.message)}\nRun rich-ri --help for usage."
45
+ end
46
+ 1
47
+ rescue Interrupt
48
+ 130
49
+ end
50
+
51
+ private
52
+
53
+ def optional_dependency(error)
54
+ error.path if error.is_a?(LoadError) && %w[profile webrick].include?(error.path)
55
+ end
56
+
57
+ def incompatible_cache?(error)
58
+ (error.is_a?(TypeError) && error.message.match?(/class RDoc::Markup::\w+ not a struct/)) ||
59
+ (error.is_a?(ArgumentError) && error.message == "dump format error")
60
+ end
61
+
62
+ def color?(mode)
63
+ mode == "always" || (mode == "auto" && $stdout.tty? && ENV["TERM"] != "dumb" && ENV.fetch("NO_COLOR", "").empty?)
64
+ end
65
+
66
+ def action(name, value = nil, options:)
67
+ case name
68
+ when :help then help(options)
69
+ when :version then puts "rich-ri #{VERSION}"
70
+ when :config_path then puts options.configuration_path
71
+ when :show_config then puts Psych.dump(options.settings)
72
+ when :completion then puts File.read(File.expand_path("../../completions/rich-ri.#{value}", __dir__))
73
+ when :man_path then puts Manual.new.path
74
+ when :man then return Manual.new.show(color: color?(options.color), theme: options.theme)
75
+ when :install_man
76
+ unless options.driver_options[:names].empty?
77
+ raise ArgumentError, "--install-man does not accept lookup names; use --install-man=DIR"
78
+ end
79
+
80
+ return Manual.new.install(value)
81
+ end
82
+ 0
83
+ end
84
+
85
+ def dump(path)
86
+ unless File.file?(path) && File.readable?(path)
87
+ raise ArgumentError, "RI cache must be a readable regular file: #{path}"
88
+ end
89
+
90
+ Driver.dump(path)
91
+ end
92
+
93
+ def help(options)
94
+ options.parser.to_s.each_line do |line|
95
+ role = line.start_with?("Usage:") ? :title : :heading
96
+ styled = line.match?(/\A\S.*:/) ? options.theme.paint(line, role, enabled: color?(options.color)) : line
97
+ print styled
98
+ end
99
+ end
100
+
101
+ def with_pager(command = nil)
102
+ previous_pager = ENV.fetch("RI_PAGER", nil)
103
+ ENV["RI_PAGER"] = command if command
104
+ previous = ENV.fetch("LESS", nil)
105
+ ENV["LESS"] = "#{previous || '-Fi'} -R"
106
+ yield
107
+ ensure
108
+ ENV["LESS"] = previous
109
+ ENV["RI_PAGER"] = previous_pager
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RichRI
4
+ # Explicit colors degrade to the closest xterm palette entry. Named ANSI
5
+ # colors stay symbolic so terminal themes retain control over their palette.
6
+ class Color
7
+ NAMES = %w[black red green yellow blue magenta cyan white].freeze
8
+ BASIC = [
9
+ [0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0],
10
+ [0, 0, 128], [128, 0, 128], [0, 128, 128], [192, 192, 192],
11
+ [128, 128, 128], [255, 0, 0], [0, 255, 0], [255, 255, 0],
12
+ [0, 0, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255]
13
+ ].map(&:freeze).freeze
14
+ CUBE = [0, 95, 135, 175, 215, 255].freeze
15
+ PALETTE = (BASIC + CUBE.repeated_permutation(3).to_a +
16
+ Array.new(24) { |index| Array.new(3, 8 + (index * 10)) }).map(&:freeze).freeze
17
+
18
+ def initialize(value)
19
+ @value = value
20
+ @index = NAMES.index(value.delete_prefix("bright_"))
21
+ @index += 8 if @index && value.start_with?("bright_")
22
+ @number = value.to_i if value.match?(/\A(?:0|[1-9]\d{0,2})\z/) && value.to_i <= 255
23
+ @rgb = value.delete_prefix("#").scan(/../).map { |part| part.to_i(16) } if value.match?(/\A#[0-9a-fA-F]{6}\z/)
24
+ return if @index || @number || @rgb || value == "default"
25
+
26
+ raise ArgumentError, "invalid color #{value.inspect}; use an ANSI name, 0..255, #RRGGBB or default"
27
+ end
28
+
29
+ def sgr(depth, background: false)
30
+ return background ? "49" : "39" if @value == "default"
31
+ return basic(@index, background:) if @index
32
+
33
+ rgb = @rgb || PALETTE.fetch(@number)
34
+ return basic(nearest(rgb, BASIC), background:) if depth == "basic"
35
+
36
+ prefix = background ? "48" : "38"
37
+ return "#{prefix};5;#{@number || nearest(rgb, PALETTE)}" if depth == "256" || @number
38
+
39
+ "#{prefix};2;#{rgb.join(';')}"
40
+ end
41
+
42
+ private
43
+
44
+ def basic(index, background:)
45
+ ((index < 8 ? 30 : 90) + (index % 8) + (background ? 10 : 0)).to_s
46
+ end
47
+
48
+ def nearest(rgb, palette)
49
+ palette.each_index.min_by do |index|
50
+ palette[index].zip(rgb).sum { |candidate, channel| (candidate - channel)**2 }
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RichRI
4
+ # The protocol is tab-separated value/description pairs. Only source options
5
+ # reach the driver: pressing Tab can never start a pager, server or cache dump.
6
+ class Completion
7
+ SOURCES = /\A--(?:no-)?(?:system|site|home|gems|standard-docs)\z/
8
+ VALUES = %w[-w --width --server --dump --bat-theme --shell-theme --pager-command].freeze
9
+
10
+ def write(words, io)
11
+ # Bound discovery even when a documentation store is unusually large.
12
+ Timeout.timeout(4) do
13
+ words = words.drop(1).map { |word| shell_word(word) } if %w[--shell=bash --shell=zsh].include?(words.first)
14
+ candidates(words).each do |value, description|
15
+ next if [value, description].any? { |text| text.match?(/[\t\r\n]/) || RichRI.sanitize(text) != text }
16
+
17
+ io.puts "#{value}\t#{description}"
18
+ end
19
+ end
20
+ rescue StandardError
21
+ # A broken/missing RI store must not interrupt shell input.
22
+ nil
23
+ end
24
+
25
+ def candidates(words)
26
+ return [] if words[0...-1].include?("--install-man")
27
+
28
+ current, previous, prefix = context(words)
29
+ values = option_values(previous, current)
30
+ values ||= if current.start_with?("-") && !words[0...-1].include?("--")
31
+ Options.new.entries
32
+ else
33
+ names(words[0...-1], current).map { |v| [v, ""] }
34
+ end
35
+ values.select { |value, _| value.start_with?(current) }
36
+ .map { |value, desc| [prefix + value, desc] }.uniq.sort
37
+ end
38
+
39
+ private
40
+
41
+ def option_values(previous, current)
42
+ case previous
43
+ when "--color" then %w[auto always never].map { |v| [v, "Color mode"] }
44
+ when "--format", "-f" then Options.formats.map { |v| [v, "RDoc formatter"] }
45
+ when "--completion" then %w[bash zsh fish].map { |v| [v, "Shell completion script"] }
46
+ when "--theme" then Theme::NAMES.map { |v| [v, "Page theme"] }
47
+ when "--color-depth" then Theme::DEPTHS.map { |v| [v, "Terminal color depth"] }
48
+ when "--style" then styles(current)
49
+ when "--config" then paths(current)
50
+ when "--doc-dir", "-d", "--install-man" then directories(current)
51
+ when *VALUES then []
52
+ end
53
+ end
54
+
55
+ def shell_word(word)
56
+ # Bash and Zsh retain quotes in their words. Shellwords removes them without
57
+ # evaluating substitutions; the current word may have an unclosed quote.
58
+ ["", "'", '"'].each do |suffix|
59
+ parts = Shellwords.split(word + suffix)
60
+ return parts.first.to_s if parts.length <= 1
61
+ rescue ArgumentError
62
+ next
63
+ end
64
+ word
65
+ end
66
+
67
+ def context(words)
68
+ current = words.last || ""
69
+ previous = words[-2]
70
+ prefix = ""
71
+ if current.start_with?("--") && current.include?("=")
72
+ previous, current = current.split("=", 2)
73
+ prefix = "#{previous}="
74
+ elsif previous == "--color"
75
+ # Optional values require '='; a bare switch does not consume a name.
76
+ previous = nil
77
+ end
78
+ [current, previous, prefix]
79
+ end
80
+
81
+ def styles(prefix)
82
+ return [] if prefix.include?("=")
83
+
84
+ RichRI::COLORS.keys.map { |role| ["#{role}=", "Override #{role} style"] }
85
+ end
86
+
87
+ def directories(prefix)
88
+ paths(prefix, directories_only: true).map { |path, _description| [path, "Documentation directory"] }
89
+ end
90
+
91
+ def paths(prefix, directories_only: false)
92
+ # Escape glob metacharacters typed by the user; do not interpret patterns.
93
+ escaped = prefix.gsub(/[\[\]{}*?\\]/) { |char| "\\#{char}" }
94
+ Dir.glob("#{escaped}*").filter_map do |path|
95
+ directory = File.directory?(path)
96
+ next if directories_only && !directory
97
+ next unless directory || File.file?(path)
98
+
99
+ [directory ? "#{path}/" : path, directory ? "Directory" : "Configuration file"]
100
+ end
101
+ end
102
+
103
+ def names(words, prefix)
104
+ defaults = Shellwords.split(ENV.fetch("RI", ""))
105
+ configured = Configuration.new(words).arguments
106
+ args = [defaults, configured, words].flat_map { |layer| source_arguments(layer) }
107
+ options = Options.new.parse(args, defaults: "", configuration: false).driver_options
108
+ Driver.new(options.merge(use_stdout: true, interactive: false)).complete(prefix)
109
+ end
110
+
111
+ def source_arguments(words)
112
+ args = []
113
+ flags = Options.new.entries.map { |flag, _description| flag.delete_suffix("=") }
114
+ index = 0
115
+ while index < words.length
116
+ word = words[index]
117
+ break if word == "--"
118
+
119
+ raise OptionParser::InvalidOption, word if word.start_with?("--") && !flags.include?(word.split("=", 2).first)
120
+
121
+ if word.match?(SOURCES) || word.start_with?("--doc-dir=") || (word.start_with?("-d") && word.length > 2)
122
+ args << word
123
+ elsif %w[--doc-dir -d].include?(word)
124
+ args.concat(words[index, 2])
125
+ index += 1
126
+ elsif Configuration::VALUE_OPTIONS.include?(word) || word == "--config"
127
+ # An option value that resembles a source flag is still just data.
128
+ index += 1
129
+ end
130
+ index += 1
131
+ end
132
+ args
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RichRI
4
+ # Only the user's selected file is read; documentation directories never
5
+ # supply configuration, and YAML values are data rather than Ruby objects.
6
+ class Configuration
7
+ KEYS = %w[theme color color_depth width pager bat_theme shell_theme all expand_refs doc_dirs sources styles].freeze
8
+ SOURCES = %w[system site home gems].freeze
9
+ ENVIRONMENT = {
10
+ "RICH_RI_THEME" => "theme", "RICH_RI_COLOR" => "color", "RICH_RI_COLOR_DEPTH" => "color_depth",
11
+ "RICH_RI_WIDTH" => "width", "RICH_RI_BAT_THEME" => "bat_theme", "RICH_RI_SHELL_THEME" => "shell_theme"
12
+ }.freeze
13
+ VALUE_OPTIONS = %w[-w --width -f --format -d --doc-dir --dump --completion --theme --color-depth
14
+ --style --bat-theme --shell-theme --pager-command].freeze
15
+ MAX_BYTES = 65_536
16
+
17
+ attr_reader :path, :arguments
18
+
19
+ def initialize(argv, env: ENV, load: true)
20
+ @env = env
21
+ @path, explicit = select_path(argv)
22
+ @arguments = []
23
+ return unless load
24
+
25
+ data = @path && (explicit || File.exist?(@path)) ? read_file : {}
26
+ @arguments = file_arguments(data) + environment_arguments
27
+ rescue Psych::Exception => e
28
+ raise ArgumentError, "Invalid configuration #{@path}: #{e.message}"
29
+ end
30
+
31
+ def self.switches(argv)
32
+ options = []
33
+ index = 0
34
+ while index < argv.length
35
+ word = argv[index]
36
+ break if word == "--"
37
+
38
+ options << [word, argv[index + 1]]
39
+ index += VALUE_OPTIONS.include?(word) || word == "--config" ? 2 : 1
40
+ end
41
+ options
42
+ end
43
+
44
+ def self.text!(value, name)
45
+ if value.is_a?(String) && !value.strip.empty? && RichRI.sanitize(value) == value && !value.match?(/[\r\n\t]/)
46
+ return
47
+ end
48
+
49
+ raise ArgumentError, "#{name} must be a nonempty string without control characters"
50
+ end
51
+
52
+ private
53
+
54
+ def select_path(argv)
55
+ base = @env["XDG_CONFIG_HOME"]
56
+ base = File.join(@env.fetch("HOME") { Dir.home }, ".config") unless base&.start_with?("/")
57
+ explicit = !@env.fetch("RICH_RI_CONFIG", "").empty?
58
+ path = explicit ? @env.fetch("RICH_RI_CONFIG") : File.join(base, "rich-ri/config.yml")
59
+ self.class.switches(argv).each do |word, argument|
60
+ if word == "--no-config"
61
+ path = nil
62
+ elsif word == "--config" || word.start_with?("--config=")
63
+ path = word == "--config" ? argument : word.split("=", 2).last
64
+ raise ArgumentError, "--config requires a nonempty file path" if path.nil? || path.empty?
65
+
66
+ explicit = true
67
+ end
68
+ end
69
+ self.class.text!(path, "Configuration path") if path
70
+ [path && File.expand_path(path), explicit]
71
+ end
72
+
73
+ def read_file
74
+ raise ArgumentError, "Configuration is not a readable regular file: #{@path}" unless File.file?(@path)
75
+
76
+ content = File.read(@path, MAX_BYTES + 1)
77
+ raise ArgumentError, "Configuration exceeds #{MAX_BYTES} bytes: #{@path}" if content.bytesize > MAX_BYTES
78
+
79
+ stream = Psych.parse_stream(content, filename: @path)
80
+ raise ArgumentError, "Configuration must contain one YAML document: #{@path}" if stream.children.length > 1
81
+
82
+ check_duplicate_keys(stream)
83
+ data = Psych.safe_load(content, permitted_classes: [], permitted_symbols: [], aliases: false, filename: @path)
84
+ data = {} if data.nil?
85
+ mapping!(data, KEYS, "configuration")
86
+ data
87
+ end
88
+
89
+ def check_duplicate_keys(root)
90
+ pending = [[root, 0]]
91
+ until pending.empty?
92
+ node, depth = pending.pop
93
+ raise ArgumentError, "Configuration nesting exceeds 20 levels: #{@path}" if depth > 20
94
+
95
+ if node.is_a?(Psych::Nodes::Mapping)
96
+ keys = node.children.each_slice(2).map { |key, _value| key.value if key.is_a?(Psych::Nodes::Scalar) }
97
+ raise ArgumentError, "Duplicate configuration key in #{@path}" unless keys.uniq.length == keys.length
98
+ end
99
+ pending.concat(Array(node.children).map { |child| [child, depth + 1] })
100
+ end
101
+ end
102
+
103
+ def mapping!(value, keys, context)
104
+ raise ArgumentError, "#{context} must be a mapping" unless value.is_a?(Hash)
105
+
106
+ unknown = value.keys - keys
107
+ raise ArgumentError, "Unknown #{context} key: #{unknown.first.inspect}" unless unknown.empty?
108
+ end
109
+
110
+ def file_arguments(data)
111
+ args = data.except("sources", "styles", "doc_dirs").flat_map { |key, value| setting(key, value) }
112
+ sources = data.fetch("sources", {})
113
+ mapping!(sources, SOURCES, "sources")
114
+ args.concat(sources.flat_map { |key, value| boolean(key, value) })
115
+ styles = data.fetch("styles", {})
116
+ mapping!(styles, COLORS.keys.map(&:to_s), "styles")
117
+ args.concat(styles.flat_map { |key, value| style(key, value) })
118
+ directories = data.fetch("doc_dirs", [])
119
+ raise ArgumentError, "doc_dirs must be a list of directory paths" unless directories.is_a?(Array)
120
+
121
+ directories.each do |directory|
122
+ self.class.text!(directory, "doc_dirs")
123
+ args.push("--doc-dir", File.expand_path(directory, File.dirname(@path)))
124
+ end
125
+ args
126
+ end
127
+
128
+ def environment_arguments
129
+ args = ENVIRONMENT.flat_map do |name, key|
130
+ value = @env[name]
131
+ next [] if value.nil? || value.empty?
132
+
133
+ setting(key, key == "width" && value.match?(/\A[0-9]+\z/) ? value.to_i : value)
134
+ end
135
+ args.concat(setting("bat_theme", @env["BAT_THEME"])) if @env["BAT_THEME"] && !@env["BAT_THEME"].empty? &&
136
+ @env.fetch("RICH_RI_BAT_THEME", "").empty?
137
+ unless @env.fetch("RI_PAGER", "").empty?
138
+ self.class.text!(@env["RI_PAGER"], "RI_PAGER")
139
+ args << "--pager-command=#{@env['RI_PAGER']}"
140
+ end
141
+ @env.each do |name, value|
142
+ next unless name.start_with?("RICH_RI_STYLE_") && !value.to_s.empty?
143
+
144
+ args.concat(style(name.delete_prefix("RICH_RI_STYLE_").downcase, value))
145
+ end
146
+ args
147
+ end
148
+
149
+ def setting(key, value)
150
+ return boolean(key.tr("_", "-"), value) if %w[all expand_refs].include?(key)
151
+ return boolean("pager", value) if key == "pager" && [true, false].include?(value)
152
+
153
+ if key == "width"
154
+ raise ArgumentError, "width must be an integer of at least 20" unless value.is_a?(Integer) && value >= 20
155
+ else
156
+ self.class.text!(value, key)
157
+ end
158
+ values = { "theme" => Theme::NAMES, "color" => %w[auto always never], "color_depth" => Theme::DEPTHS }[key]
159
+ raise ArgumentError, "#{key} must be one of: #{values.join(', ')}" if values && !values.include?(value)
160
+
161
+ flag = key == "pager" ? "pager-command" : key.tr("_", "-")
162
+ key == "pager" ? ["--pager", "--#{flag}=#{value}"] : ["--#{flag}=#{value}"]
163
+ end
164
+
165
+ def boolean(key, value)
166
+ raise ArgumentError, "#{key} must be true or false" unless [true, false].include?(value)
167
+
168
+ ["--#{'no-' unless value}#{key}"]
169
+ end
170
+
171
+ def style(role, value)
172
+ self.class.text!(value, "style #{role}")
173
+ Theme.new(styles: { role => value })
174
+ ["--style=#{role}=#{value}"]
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RichRI
4
+ # Configuration switches share the normal parser, so defaults and explicit
5
+ # arguments use the same validation and completion descriptions.
6
+ module ConfigurationOptions
7
+ attr_reader :theme, :bat_theme, :shell_theme, :pager_command, :configuration_path
8
+
9
+ def configuration_options
10
+ @theme_name = "terminal"
11
+ @color_depth = "auto"
12
+ @styles = {}
13
+ @bat_theme = "base16"
14
+ @shell_theme = "ansi"
15
+ @pager_enabled = true
16
+ @parser.separator ""
17
+ @parser.separator "Configuration and themes:"
18
+ @parser.on("--config=FILE", "Read a YAML configuration file instead of the user default.") do |path|
19
+ Configuration.text!(path, "Configuration path")
20
+ @configuration_path = File.expand_path(path)
21
+ end
22
+ @parser.on("--no-config", "Skip the configuration file; environment options still apply.") do
23
+ @configuration_path = nil
24
+ end
25
+ @parser.on("--config-path", "Print the selected configuration path; blank when disabled.") do
26
+ @action = [:config_path]
27
+ end
28
+ @parser.on("--show-config", "Print effective preferences as YAML without opening documentation.") do
29
+ @action = [:show_config]
30
+ end
31
+ theme_options
32
+ end
33
+
34
+ def theme_options
35
+ @parser.on("--theme=NAME", Theme::NAMES, "Palette: terminal (default), dark or light.") { |name| @theme_name = name }
36
+ @parser.on("--color-depth=DEPTH", Theme::DEPTHS,
37
+ "Color depth: auto (default), basic, 256 or truecolor.") do |depth|
38
+ @color_depth = depth
39
+ end
40
+ @parser.on("--style=ROLE=STYLE",
41
+ "Override a style role; repeat for several roles. Example: method=green:bold.") do |value|
42
+ role, style = value.split("=", 2)
43
+ Theme.new(styles: { role => style })
44
+ @styles[role] = style
45
+ end
46
+ @parser.on("--bat-theme=NAME", "bat theme for tagged non-Ruby, non-shell code (default: base16).") do |name|
47
+ Configuration.text!(name, "bat_theme")
48
+ @bat_theme = name
49
+ end
50
+ @parser.on("--shell-theme=NAME", "bat theme for shell input (default: ansi).") do |name|
51
+ Configuration.text!(name, "shell_theme")
52
+ @shell_theme = name
53
+ end
54
+ @parser.on("--pager-command=COMMAND", "Choose a trusted pager command, overriding RI_PAGER/PAGER.") do |command|
55
+ Configuration.text!(command, "pager command")
56
+ @pager_command = command
57
+ end
58
+ end
59
+
60
+ def settings
61
+ { "theme" => @theme_name, "color" => @color, "color_depth" => @color_depth,
62
+ "width" => @driver_options.fetch(:width), "pager" => @pager_enabled && (@pager_command || true),
63
+ "bat_theme" => @bat_theme, "shell_theme" => @shell_theme,
64
+ "all" => @driver_options.fetch(:show_all), "expand_refs" => @driver_options.fetch(:expand_refs),
65
+ "doc_dirs" => @driver_options.fetch(:extra_doc_dirs).map(&:dup),
66
+ "sources" => Configuration::SOURCES.to_h { |key| [key, @driver_options.fetch(:"use_#{key}")] },
67
+ "styles" => @styles.transform_values(&:dup) }
68
+ end
69
+
70
+ private
71
+
72
+ def configured_defaults(argv, defaults, enabled)
73
+ selection = Configuration.new(argv, load: false)
74
+ @configuration_path = selection.path
75
+ return [] if Configuration.switches(argv).any? { |word, _| word == "--config-path" }
76
+
77
+ words = Shellwords.split(defaults)
78
+ @parser.parse!(words)
79
+ @parser.parse!(Configuration.new(argv).arguments) if enabled
80
+ words
81
+ rescue ArgumentError, OptionParser::ParseError, SystemCallError
82
+ raise unless recovery_request?(argv)
83
+
84
+ initialize
85
+ @configuration_path = selection&.path
86
+ []
87
+ end
88
+
89
+ def recovery_request?(argv)
90
+ Configuration.switches(argv).any? do |word, _|
91
+ %w[--help -h --version -v --config-path --completion].include?(word) || word.start_with?("--completion=")
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RichRI
4
+ class Driver < RDoc::RI::Driver
5
+ # RDoc's class listing writes directly to its pager, outside the formatter.
6
+ class ListOutput
7
+ def initialize(io)
8
+ @io = io
9
+ end
10
+
11
+ def puts(*values)
12
+ @io.puts(*values.map { |value| RichRI.sanitize(value.to_s) })
13
+ end
14
+
15
+ def tty?
16
+ @io.tty?
17
+ end
18
+ end
19
+
20
+ def self.default_options
21
+ columns = $stdout.tty? ? $stdout.winsize.last : 80
22
+ columns = 80 unless columns.positive?
23
+ super.merge(width: (columns - 2).clamp(30, 96))
24
+ rescue SystemCallError
25
+ super
26
+ end
27
+
28
+ def initialize(options)
29
+ @rich_ri_color = options.delete(:rich_ri_color)
30
+ @rich_ri_theme = options.delete(:rich_ri_theme) || Theme.new
31
+ @rich_ri_bat_theme = options.delete(:rich_ri_bat_theme) || ENV.fetch("BAT_THEME", "base16")
32
+ @rich_ri_shell_theme = options.delete(:rich_ri_shell_theme) || "ansi"
33
+ super
34
+ end
35
+
36
+ def formatter(io)
37
+ return super if @formatter_klass
38
+
39
+ Formatter.new(color: @rich_ri_color, classes: classes, theme: @rich_ri_theme,
40
+ bat_theme: @rich_ri_bat_theme, shell_theme: @rich_ri_shell_theme)
41
+ end
42
+
43
+ def run
44
+ return super unless @list_doc_dirs && !@formatter_klass
45
+
46
+ puts(@doc_dirs.map { |path| RichRI.sanitize(path) })
47
+ end
48
+
49
+ def page
50
+ super { |io| yield(@list && !@formatter_klass ? ListOutput.new(io) : io) }
51
+ end
52
+
53
+ def start_server
54
+ # Surface missing dependencies through CLI errors instead of RDoc's abort.
55
+ require "webrick"
56
+ super
57
+ end
58
+
59
+ def complete(name)
60
+ # RI completes classes/methods but does not offer ruby: or gem pages.
61
+ # Use the loaded stores so discovery follows this Ruby and --doc-dir.
62
+ if (match = /\A([^:]+):([^:]*)\z/.match(name))
63
+ source, prefix = match.captures
64
+ matching = stores.select do |store|
65
+ store.source == source || (store.type == :gem && store.source.match?(/\A#{Regexp.escape(source)}-\d/))
66
+ end
67
+ return matching.flat_map { |store| store.cache[:pages] || [] }
68
+ .select { |page| page.start_with?(prefix) }
69
+ .map { |page| "#{source}:#{page}" }.uniq.sort
70
+ end
71
+
72
+ candidates = super
73
+ candidates.push("#{name}#", "#{name}.", "#{name}::") if classes.key?(name)
74
+ unless name.match?(/[.#:]/)
75
+ stores.each do |store|
76
+ next if (store.cache[:pages] || []).empty?
77
+
78
+ source = store.type == :gem ? store.source.sub(/-\d[^-]*\z/, "") : store.source
79
+ candidates << "#{source}:" if source.start_with?(name)
80
+ end
81
+ end
82
+ candidates.uniq.sort
83
+ end
84
+
85
+ def render_method_arguments(out, arglists)
86
+ start = out.parts.length
87
+ super
88
+ return if @formatter_klass
89
+
90
+ out.parts[start..].grep(RDoc::Markup::Verbatim).each { |part| part.format = :rich_ri_signature }
91
+ end
92
+
93
+ def render_method_type_signature(out, lines)
94
+ start = out.parts.length
95
+ super
96
+ return if @formatter_klass
97
+
98
+ out.parts[start..].grep(RDoc::Markup::Verbatim).each { |part| part.format = :rbs }
99
+ end
100
+
101
+ def add_method_list(out, methods, name)
102
+ return super if @formatter_klass
103
+ return if methods.empty?
104
+
105
+ out << RDoc::Markup::Heading.new(2, "#{name}:")
106
+ out << RDoc::Markup::BlankLine.new
107
+ out << MethodList.new(2, methods.join(", "))
108
+ out << RDoc::Markup::BlankLine.new
109
+ end
110
+ end
111
+ end