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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +57 -0
- data/LICENSE.txt +21 -0
- data/README.md +85 -0
- data/SECURITY.md +55 -0
- data/completions/rich-ri.bash +23 -0
- data/completions/rich-ri.fish +9 -0
- data/completions/rich-ri.zsh +18 -0
- data/docs/compatibility.md +40 -0
- data/docs/config.example.yml +49 -0
- data/docs/configuration.md +254 -0
- data/docs/development.md +110 -0
- data/docs/images/README.md +29 -0
- data/docs/maintenance.md +105 -0
- data/docs/shell-completion.md +51 -0
- data/docs/troubleshooting.md +95 -0
- data/docs/usage.md +147 -0
- data/exe/rich-ri +6 -0
- data/lib/rich_ri/ansi.rb +35 -0
- data/lib/rich_ri/bat.rb +73 -0
- data/lib/rich_ri/cli.rb +112 -0
- data/lib/rich_ri/color.rb +54 -0
- data/lib/rich_ri/completion.rb +135 -0
- data/lib/rich_ri/configuration.rb +177 -0
- data/lib/rich_ri/configuration_options.rb +95 -0
- data/lib/rich_ri/driver.rb +111 -0
- data/lib/rich_ri/formatter.rb +169 -0
- data/lib/rich_ri/highlighter.rb +195 -0
- data/lib/rich_ri/manual.rb +74 -0
- data/lib/rich_ri/options.rb +160 -0
- data/lib/rich_ri/style.rb +48 -0
- data/lib/rich_ri/theme.rb +88 -0
- data/lib/rich_ri/version.rb +5 -0
- data/lib/rich_ri.rb +23 -0
- data/man/man1/rich-ri.1 +419 -0
- metadata +209 -0
data/lib/rich_ri/cli.rb
ADDED
|
@@ -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
|