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
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RichRI
|
|
4
|
+
class MethodList < RDoc::Markup::IndentedParagraph
|
|
5
|
+
def accept(visitor)
|
|
6
|
+
visitor.respond_to?(:accept_method_list) ? visitor.accept_method_list(self) : super
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class Formatter < RDoc::Markup::ToAnsi
|
|
11
|
+
REFERENCES = /(?<!\w)(?:[A-Z]\w*(?:::\w+)*(?:[#.]\w+[!?=]?)?|\#\w+[!?=]?)/
|
|
12
|
+
|
|
13
|
+
def initialize(color: true, classes: {}, theme: Theme.new, bat_theme: ENV.fetch("BAT_THEME", "base16"),
|
|
14
|
+
shell_theme: "ansi")
|
|
15
|
+
super()
|
|
16
|
+
@color = color
|
|
17
|
+
@classes = classes
|
|
18
|
+
@theme = theme
|
|
19
|
+
@highlighter = Highlighter.new(color, theme:, bat_theme:, shell_theme:)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def paint(text, *roles)
|
|
23
|
+
@theme.paint(text, *roles, enabled: @color)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def start_accepting
|
|
27
|
+
super
|
|
28
|
+
@res = []
|
|
29
|
+
@first_heading = true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def accept_heading(heading)
|
|
33
|
+
role = if @first_heading && heading.level == 1
|
|
34
|
+
:title
|
|
35
|
+
elsif heading.level <= 2
|
|
36
|
+
:heading
|
|
37
|
+
else
|
|
38
|
+
:subheading
|
|
39
|
+
end
|
|
40
|
+
@first_heading = false
|
|
41
|
+
text = "#{'=' * heading.level} #{RichRI.plain(attributes(heading.text))}"
|
|
42
|
+
wrap paint(text, role)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def accept_rule(_rule)
|
|
46
|
+
use_prefix or @res << (" " * @indent)
|
|
47
|
+
@res << paint("-" * [@width - @indent, 1].max, :muted) << "\n"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def accept_paragraph(paragraph)
|
|
51
|
+
text = paragraph.text(@hard_break)
|
|
52
|
+
if text.start_with?("(from ")
|
|
53
|
+
wrap paint(RichRI.plain(attributes(text)), :muted)
|
|
54
|
+
else
|
|
55
|
+
super
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def accept_verbatim(verbatim)
|
|
60
|
+
text = @highlighter.highlight(verbatim.text, verbatim.format)
|
|
61
|
+
text.each_line do |line|
|
|
62
|
+
@res << (" " * (@indent + 2)) unless line == "\n"
|
|
63
|
+
@res << line
|
|
64
|
+
end
|
|
65
|
+
@res << "\n" unless text.end_with?("\n")
|
|
66
|
+
@res << "\n"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def accept_raw(raw)
|
|
70
|
+
# Markdown HTML blocks bypass the inline visitors in RDoc.
|
|
71
|
+
@res << RichRI.sanitize(raw.parts.join("\n"))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def accept_method_list(list)
|
|
75
|
+
@indent += list.indent
|
|
76
|
+
wrap paint(RichRI.sanitize(list.text(@hard_break)), :reference)
|
|
77
|
+
@indent -= list.indent
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def accept_list_item_start(item)
|
|
81
|
+
super
|
|
82
|
+
return unless @prefix
|
|
83
|
+
|
|
84
|
+
role = %i[NOTE LABEL].include?(@list_type.last) ? :label : :reference
|
|
85
|
+
@prefix = paint(RichRI.plain(@prefix), role)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def accept_list_item_end(item)
|
|
89
|
+
# ToAnsi 8.1 uses byte length for numbered prefixes; ours contain SGRs.
|
|
90
|
+
RDoc::Markup::ToRdoc.instance_method(:accept_list_item_end).bind_call(self, item)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def add_text(text)
|
|
94
|
+
text = RichRI.sanitize(text)
|
|
95
|
+
attrs = @attributes.keys
|
|
96
|
+
roles = []
|
|
97
|
+
roles << :bold if attrs.include?(:BOLD)
|
|
98
|
+
roles << :emphasis if attrs.include?(:EM)
|
|
99
|
+
roles << :strike if attrs.include?(:STRIKE)
|
|
100
|
+
roles << :code if attrs.include?(:TT)
|
|
101
|
+
text = if roles.empty?
|
|
102
|
+
text.gsub(REFERENCES) do |reference|
|
|
103
|
+
klass = reference.split(/[.#]/, 2).first
|
|
104
|
+
reference.start_with?("#") || @classes.key?(klass) ? paint(reference, :reference) : reference
|
|
105
|
+
end
|
|
106
|
+
else
|
|
107
|
+
paint(text, *roles)
|
|
108
|
+
end
|
|
109
|
+
emit_inline(text)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def handle_TIDYLINK(children, url)
|
|
113
|
+
start = @inline_output.length
|
|
114
|
+
traverse_inline_nodes(children)
|
|
115
|
+
label = RichRI.plain(@inline_output.slice!(start..))
|
|
116
|
+
emit_inline(paint(label, :link))
|
|
117
|
+
# Internal RDoc addresses are implementation details, not useful terminal
|
|
118
|
+
# URLs. External destinations remain visible and can be opened/copied.
|
|
119
|
+
return if url.start_with?("rdoc-", "#") || url == label
|
|
120
|
+
|
|
121
|
+
emit_inline(" (#{paint(RichRI.sanitize(url), :reference)})")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def calculate_text_width(text)
|
|
125
|
+
RichRI.width(text)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def wrap(text)
|
|
129
|
+
return if text.nil? || text.empty?
|
|
130
|
+
|
|
131
|
+
limit = [@width - @indent, 12].max
|
|
132
|
+
prefix = @prefix || (" " * @indent)
|
|
133
|
+
@prefix = nil
|
|
134
|
+
line = +""
|
|
135
|
+
pending_space = ""
|
|
136
|
+
flush = lambda do
|
|
137
|
+
@res << prefix << line
|
|
138
|
+
@res << RESET if @color
|
|
139
|
+
@res << "\n"
|
|
140
|
+
prefix = " " * @indent
|
|
141
|
+
line = +""
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
text.scan(/(?:\e\[[\d;]*m|[^\s\e])+|[^\S\n]+|\n/).each do |word|
|
|
145
|
+
if word == "\n"
|
|
146
|
+
flush.call
|
|
147
|
+
pending_space = ""
|
|
148
|
+
elsif word.match?(/\A\s+\z/)
|
|
149
|
+
pending_space = " " unless line.empty?
|
|
150
|
+
else
|
|
151
|
+
if !line.empty? && RichRI.width(line + pending_space + word) > limit
|
|
152
|
+
flush.call
|
|
153
|
+
pending_space = ""
|
|
154
|
+
end
|
|
155
|
+
# Split long URLs/identifiers using terminal cell widths, never SGR
|
|
156
|
+
# bytes or the middle of a wide Unicode character.
|
|
157
|
+
chunks = Reline::Unicode.split_by_width(word, limit).first
|
|
158
|
+
chunks.pop while chunks.length > 1 && RichRI.plain(chunks.last).empty?
|
|
159
|
+
chunks.each_with_index do |chunk, index|
|
|
160
|
+
flush.call if index.positive?
|
|
161
|
+
line << pending_space << chunk
|
|
162
|
+
pending_space = ""
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
flush.call unless line.empty?
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RichRI
|
|
4
|
+
class Highlighter
|
|
5
|
+
SHELL_FORMATS = %i[sh bash zsh shell].freeze
|
|
6
|
+
SESSION_FORMATS = %i[console shell-session shell_session sh-session].freeze
|
|
7
|
+
|
|
8
|
+
def initialize(enabled, theme: Theme.new, bat_theme: ENV.fetch("BAT_THEME", "base16"), shell_theme: "ansi")
|
|
9
|
+
@enabled = enabled
|
|
10
|
+
@theme = theme
|
|
11
|
+
@bat_theme = bat_theme
|
|
12
|
+
@shell_theme = shell_theme
|
|
13
|
+
@bat = Bat.new
|
|
14
|
+
@cache = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def highlight(text, format = nil)
|
|
18
|
+
text = RichRI.sanitize(text)
|
|
19
|
+
return text unless @enabled
|
|
20
|
+
|
|
21
|
+
@cache[[text, format]] ||= if %i[ruby rb rich_ri_signature].include?(format)
|
|
22
|
+
ruby(text, signature: format == :rich_ri_signature)
|
|
23
|
+
elsif format.nil?
|
|
24
|
+
shell_session(text) || (ruby?(text) ? ruby(text) : text)
|
|
25
|
+
elsif SHELL_FORMATS.include?(format)
|
|
26
|
+
shell_session(text) || other_language(text, format, theme: @shell_theme)
|
|
27
|
+
elsif SESSION_FORMATS.include?(format)
|
|
28
|
+
shell_session(text) || text
|
|
29
|
+
elsif %i[c cpp javascript js json yaml yml diff sql rbs].include?(format)
|
|
30
|
+
other_language(text, format)
|
|
31
|
+
else
|
|
32
|
+
text
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def shell_prompt(line)
|
|
37
|
+
match = /\A(?<indent>[ \t]*)(?<prompt>\$[ \t]+)(?<command>[^\r\n]+)(?<ending>\r?\n)?\z/.match(line)
|
|
38
|
+
return unless match
|
|
39
|
+
|
|
40
|
+
# Shellwords only tokenizes text; examples are never evaluated. Accept a
|
|
41
|
+
# command name/path, optionally preceded by environment assignments.
|
|
42
|
+
words = Shellwords.shellsplit(match[:command])
|
|
43
|
+
# A here-document can contain literal "$ command" lines. Leave this
|
|
44
|
+
# ambiguous, multi-line shell input alone rather than invent prompts.
|
|
45
|
+
return if words.any? { |word| word.start_with?("<<") }
|
|
46
|
+
|
|
47
|
+
words.shift while words.first&.match?(/\A[A-Za-z_]\w*=/)
|
|
48
|
+
executable = words.first
|
|
49
|
+
return unless executable&.match?(%r{\A(?:[A-Za-z_][\w.+-]*|(?:/|\./|\.\./|~/)\S+)\z})
|
|
50
|
+
|
|
51
|
+
match
|
|
52
|
+
rescue ArgumentError
|
|
53
|
+
# Unclosed quotes and other ambiguous examples stay unclassified.
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def shell_session(text)
|
|
58
|
+
lines = text.lines
|
|
59
|
+
first = lines.find { |line| !line.strip.empty? }
|
|
60
|
+
initial = first && shell_prompt(first)
|
|
61
|
+
return unless initial
|
|
62
|
+
|
|
63
|
+
# Require the first meaningful line to be a prompt. This avoids treating
|
|
64
|
+
# "$ command" inside a Ruby string/heredoc or ordinary prose as a shell.
|
|
65
|
+
indent = initial[:indent]
|
|
66
|
+
prompts = lines.map { |line| shell_prompt(line) }
|
|
67
|
+
prompt_start = /\A#{Regexp.escape(indent)}\$[ \t]+/
|
|
68
|
+
return if lines.zip(prompts).any? { |line, prompt| line.match?(prompt_start) && !prompt }
|
|
69
|
+
|
|
70
|
+
output = +""
|
|
71
|
+
index = 0
|
|
72
|
+
while index < lines.length
|
|
73
|
+
prompt = prompts[index]
|
|
74
|
+
unless prompt && prompt[:indent] == indent
|
|
75
|
+
output << lines[index]
|
|
76
|
+
index += 1
|
|
77
|
+
next
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
command, index = shell_command(lines, index, prompt)
|
|
81
|
+
output << command
|
|
82
|
+
end
|
|
83
|
+
output
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def shell_command(lines, index, prompt)
|
|
87
|
+
prefixes = [prompt[:indent] + @theme.paint(prompt[:prompt], :code)]
|
|
88
|
+
commands = [prompt[:command] + prompt[:ending].to_s]
|
|
89
|
+
index += 1
|
|
90
|
+
# Secondary prompts belong to input only after an explicit continuation.
|
|
91
|
+
while index < lines.length && commands.last.sub(/\r?\n\z/, "")[/\\+\z/].to_s.length.odd?
|
|
92
|
+
continuation = /\A(#{Regexp.escape(prompt[:indent])})(>[ \t]+)?(.*?)(\r?\n)?\z/.match(lines[index])
|
|
93
|
+
break unless continuation && !continuation[3].empty?
|
|
94
|
+
|
|
95
|
+
prefixes << (continuation[1] + @theme.paint(continuation[2].to_s, :code))
|
|
96
|
+
commands << (continuation[3] + continuation[4].to_s)
|
|
97
|
+
index += 1
|
|
98
|
+
end
|
|
99
|
+
source = commands.join
|
|
100
|
+
highlighted = (@cache[[source, :shell_command]] ||= other_language(source, :bash, theme: @shell_theme))
|
|
101
|
+
colored_lines = highlighted.lines
|
|
102
|
+
colored_lines = commands unless colored_lines.length == commands.length
|
|
103
|
+
[prefixes.zip(colored_lines).map(&:join).join, index]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def ruby?(text)
|
|
107
|
+
# A bare word or a prose-like command can also parse as a Ruby call.
|
|
108
|
+
return false unless text.match?(/[=\[\]{}'":.@]|\b(?:def|class|module|do|end|nil|true|false|require)\b/)
|
|
109
|
+
|
|
110
|
+
Prism.parse_success?(text)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def ruby(text, signature: false)
|
|
114
|
+
root, tokens = Prism.parse_lex(text).value
|
|
115
|
+
roles = ruby_roles(root)
|
|
116
|
+
tokens = tokens.map(&:first).reject { |token| token.type == :EOF }
|
|
117
|
+
# Heredoc tokens need source order. Byte offsets preserve Unicode and all
|
|
118
|
+
# un-tokenized whitespace, including deliberately incomplete examples.
|
|
119
|
+
tokens.sort_by! { |token| token.location.start_offset }
|
|
120
|
+
output = +""
|
|
121
|
+
offset = 0
|
|
122
|
+
previous = nil
|
|
123
|
+
tokens.each_with_index do |token, index|
|
|
124
|
+
start = token.location.start_offset
|
|
125
|
+
finish = token.location.end_offset
|
|
126
|
+
next if start < offset
|
|
127
|
+
|
|
128
|
+
output << text.byteslice(offset...start)
|
|
129
|
+
location, role = roles.bsearch { |candidate, _role| candidate.end_offset > start }
|
|
130
|
+
role = nil unless location && location.start_offset <= start && finish <= location.end_offset
|
|
131
|
+
role ||= token_role(token, previous, tokens[index + 1], signature)
|
|
132
|
+
output << (role ? @theme.paint(token.value, role) : token.value)
|
|
133
|
+
offset = finish
|
|
134
|
+
previous = token unless %i[NEWLINE IGNORED_NEWLINE COMMENT].include?(token.type)
|
|
135
|
+
end
|
|
136
|
+
output << text.byteslice(offset..)
|
|
137
|
+
rescue ArgumentError, EncodingError
|
|
138
|
+
text
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def ruby_roles(root)
|
|
142
|
+
roles = []
|
|
143
|
+
pending = [root]
|
|
144
|
+
until pending.empty?
|
|
145
|
+
node = pending.pop
|
|
146
|
+
case node
|
|
147
|
+
when Prism::DefNode
|
|
148
|
+
roles << [node.name_loc, :method]
|
|
149
|
+
when Prism::CallNode
|
|
150
|
+
# Infix operators and indexing are also calls in Ruby's AST. Only
|
|
151
|
+
# named calls and explicit receivers such as obj.+ use method colors.
|
|
152
|
+
if node.message_loc && (node.call_operator_loc || node.name.to_s.match?(/\A[[:alpha:]_]/))
|
|
153
|
+
roles << [node.message_loc, :method]
|
|
154
|
+
end
|
|
155
|
+
when Prism::SymbolNode
|
|
156
|
+
roles << [node.location, :symbol]
|
|
157
|
+
end
|
|
158
|
+
pending.concat(node.compact_child_nodes)
|
|
159
|
+
end
|
|
160
|
+
roles.sort_by { |location, _role| location.start_offset }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def token_role(token, previous, following, signature)
|
|
164
|
+
type = token.type.to_s
|
|
165
|
+
return :comment if type.start_with?("COMMENT", "EMBDOC")
|
|
166
|
+
return :symbol if type.start_with?("SYMBOL", "LABEL") || previous&.type == :SYMBOL_BEGIN
|
|
167
|
+
return :code if %w[KEYWORD_NIL KEYWORD_TRUE KEYWORD_FALSE KEYWORD_SELF].include?(type)
|
|
168
|
+
return :keyword if type.start_with?("KEYWORD_")
|
|
169
|
+
return :number if type.match?(/INTEGER|FLOAT|RATIONAL|IMAGINARY/)
|
|
170
|
+
return :constant if type == "CONSTANT"
|
|
171
|
+
return :string if type.match?(/STRING|HEREDOC|REGEXP|PERCENT_(?:LOWER|UPPER)_|CHARACTER_LITERAL|BACKTICK/)
|
|
172
|
+
return :code if type.match?(/VARIABLE|REFERENCE|EMBEXPR|EMBVAR/)
|
|
173
|
+
return :method if type == "METHOD_NAME"
|
|
174
|
+
|
|
175
|
+
# Signatures and incomplete examples can lack a complete syntax tree.
|
|
176
|
+
if (type == "IDENTIFIER") && (signature || %i[DOT AMPERSAND_DOT COLON_COLON
|
|
177
|
+
KEYWORD_DEF].include?(previous&.type) || following&.value == "(")
|
|
178
|
+
return :method
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
:operator if ruby_operator?(token)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def ruby_operator?(token)
|
|
185
|
+
%i[PERCENT PERCENT_EQUAL].include?(token.type) ||
|
|
186
|
+
%w[= => -> + - * / ** == === != =~ !~ < > <= >= <=> && || ! ~ & | ^
|
|
187
|
+
<< >> += -= *= /= **= &= |= ^= <<= >>= &&= ||= .. ... ? :].include?(token.value)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def other_language(text, format, theme: @bat_theme)
|
|
191
|
+
language = { sh: "bash", shell: "bash", js: "javascript", yml: "yaml" }.fetch(format, format.to_s)
|
|
192
|
+
@bat.highlight(text, language:, theme:) || text
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module RichRI
|
|
6
|
+
# RubyGems ships manuals inside the gem; installation into man(1)'s search
|
|
7
|
+
# path is an explicit user action so gem installation never changes the shell.
|
|
8
|
+
class Manual
|
|
9
|
+
PAGER_SETTINGS = %w[MANPAGER PAGER MANROFFOPT GROFF_NO_SGR].freeze
|
|
10
|
+
|
|
11
|
+
def path
|
|
12
|
+
File.expand_path("../../man/man1/rich-ri.1", __dir__)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def show(color:, theme: Theme.new)
|
|
16
|
+
result = system(pager_environment(color:, theme:), "man", path)
|
|
17
|
+
raise ArgumentError, "man(1) not found; install it or run rich-ri --help" if result.nil?
|
|
18
|
+
|
|
19
|
+
result ? 0 : 1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def install(target = nil)
|
|
23
|
+
directory = install_directory(target)
|
|
24
|
+
destination = File.join(directory, "rich-ri.1")
|
|
25
|
+
raise ArgumentError, "refusing to replace a symbolic link: #{destination}" if File.symlink?(destination)
|
|
26
|
+
if File.exist?(destination) && !File.file?(destination)
|
|
27
|
+
raise ArgumentError, "manual destination is not a regular file: #{destination}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
FileUtils.mkdir_p(directory)
|
|
31
|
+
FileUtils.cp(path, destination)
|
|
32
|
+
puts "Installed #{RichRI.sanitize(destination)}"
|
|
33
|
+
puts "If man rich-ri cannot find the page, add the setting for your shell:"
|
|
34
|
+
puts "Bash/Zsh:"
|
|
35
|
+
puts " export MANPATH=#{Shellwords.escape(File.dirname(directory))}:\"${MANPATH:-}\""
|
|
36
|
+
puts "Fish:"
|
|
37
|
+
puts " set -gx MANPATH #{Shellwords.escape(File.dirname(directory))} $MANPATH ''"
|
|
38
|
+
puts "Run rich-ri --install-man again after upgrading the gem."
|
|
39
|
+
0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def install_directory(target)
|
|
45
|
+
raise ArgumentError, "--install-man=DIR must not be empty" if target && target.strip.empty?
|
|
46
|
+
|
|
47
|
+
data = ENV.fetch("XDG_DATA_HOME", nil)
|
|
48
|
+
data = File.join(Dir.home, ".local/share") unless data&.start_with?("/")
|
|
49
|
+
directory = File.expand_path(target || File.join(data, "man/man1"))
|
|
50
|
+
if directory.match?(/[[:cntrl:]]/) || RichRI.sanitize(directory) != directory
|
|
51
|
+
raise ArgumentError, "manual destination must not contain control characters"
|
|
52
|
+
end
|
|
53
|
+
unless File.basename(directory) == "man1"
|
|
54
|
+
raise ArgumentError, "manual destination must be a man1 directory, such as ~/.local/share/man/man1"
|
|
55
|
+
end
|
|
56
|
+
if File.exist?(directory) && !File.directory?(directory)
|
|
57
|
+
raise ArgumentError, "manual destination is not a directory: #{directory}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
directory
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def pager_environment(color:, theme:)
|
|
64
|
+
return {} unless color
|
|
65
|
+
return {} if ENV.any? do |key, value|
|
|
66
|
+
!value.empty? && (PAGER_SETTINGS.include?(key) || key.start_with?("LESS_TERMCAP_"))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
{ "GROFF_NO_SGR" => "1", "LESS_TERMCAP_md" => "\e[#{theme.sgr(:heading)}m",
|
|
70
|
+
"LESS_TERMCAP_me" => RESET, "LESS_TERMCAP_us" => "\e[#{theme.sgr(:link)}m", "LESS_TERMCAP_ue" => RESET,
|
|
71
|
+
"LESS_TERMCAP_so" => "\e[7m", "LESS_TERMCAP_se" => RESET }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RichRI
|
|
4
|
+
# One option parser supplies both the CLI and completion descriptions.
|
|
5
|
+
class Options
|
|
6
|
+
include ConfigurationOptions
|
|
7
|
+
|
|
8
|
+
attr_reader :parser, :driver_options, :color, :action
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@driver_options = Driver.default_options
|
|
12
|
+
@color = "auto"
|
|
13
|
+
@action = nil
|
|
14
|
+
@parser = OptionParser.new
|
|
15
|
+
# Configuration selection and completion inspect flags before parsing.
|
|
16
|
+
# Require the same full option names throughout those paths.
|
|
17
|
+
@parser.require_exact = true
|
|
18
|
+
@parser.banner = "Usage: rich-ri [options] [Class | Class#method | Class.method | gem:page ...]"
|
|
19
|
+
configuration_options
|
|
20
|
+
presentation_options
|
|
21
|
+
lookup_options
|
|
22
|
+
source_options
|
|
23
|
+
utility_options
|
|
24
|
+
@parser.separator ""
|
|
25
|
+
@parser.separator "Run without a name for interactive lookup and Tab completion."
|
|
26
|
+
@parser.separator "Write long options in full; abbreviations are not accepted."
|
|
27
|
+
@parser.separator "Examples: rich-ri Array#map; rich-ri ruby:syntax/pattern_matching"
|
|
28
|
+
@parser.separator "Pager keys: / search, n next match, Space next page, q quit."
|
|
29
|
+
@parser.separator "Defaults: RI options < config file < environment < explicit arguments."
|
|
30
|
+
@parser.separator "File: $XDG_CONFIG_HOME/rich-ri/config.yml or ~/.config/rich-ri/config.yml."
|
|
31
|
+
@parser.separator "RICH_RI_CONFIG selects another file; --no-config skips it."
|
|
32
|
+
@parser.separator "Environment overrides: RICH_RI_THEME, RICH_RI_COLOR, RICH_RI_COLOR_DEPTH,"
|
|
33
|
+
@parser.separator " RICH_RI_WIDTH, RICH_RI_BAT_THEME, RICH_RI_SHELL_THEME."
|
|
34
|
+
@parser.separator "RICH_RI_STYLE_<ROLE> overrides one style; --style takes precedence."
|
|
35
|
+
@parser.separator "RI_PAGER/PAGER choose the pager; LESS sets its preferences."
|
|
36
|
+
@parser.separator "NO_COLOR and TERM=dumb disable automatic color; COLORTERM helps detect RGB."
|
|
37
|
+
@parser.separator "Styles: ANSI name, 0-255, #RRGGBB or fg=COLOR:bg=COLOR:bold:italic."
|
|
38
|
+
@parser.separator "Also supported: dim, underline, strike, reverse; none disables a role."
|
|
39
|
+
@parser.separator "Style roles:"
|
|
40
|
+
Theme::ROLES.each_slice(6) { |roles| @parser.separator " #{roles.join(', ')}" }
|
|
41
|
+
@parser.separator "See rich-ri --man or docs/configuration.md for all settings and examples."
|
|
42
|
+
@parser.separator "Ruby highlighting is built in; bat optionally highlights other languages."
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def parse(argv, defaults: ENV.fetch("RI", ""), configuration: true)
|
|
46
|
+
names = configured_defaults(argv, defaults, configuration)
|
|
47
|
+
args = argv.dup
|
|
48
|
+
@parser.parse!(args)
|
|
49
|
+
@driver_options[:names] = names + args
|
|
50
|
+
@driver_options[:use_stdout] ||= !$stdout.tty? || @driver_options[:interactive]
|
|
51
|
+
@theme = Theme.new(name: @theme_name, styles: @styles, depth: @color_depth)
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.formats
|
|
56
|
+
RDoc::Markup.constants.grep(/^To[A-Z][a-z]+$/)
|
|
57
|
+
.map { |name| name.to_s.delete_prefix("To").downcase }.sort - %w[html label test]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def entries
|
|
61
|
+
@parser.top.list.flat_map do |switch|
|
|
62
|
+
next [] unless switch.respond_to?(:long)
|
|
63
|
+
|
|
64
|
+
(switch.short + switch.long).flat_map do |flag|
|
|
65
|
+
flags = flag.include?("[no-]") ? [flag.sub("[no-]", ""), flag.sub("[no-]", "no-")] : [flag]
|
|
66
|
+
flags.map { |name| [name == "--color" ? "--color=" : name, switch.desc.join(" ")] }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def presentation_options
|
|
74
|
+
@parser.separator ""
|
|
75
|
+
@parser.separator "Presentation:"
|
|
76
|
+
@parser.on("--color[=MODE]", %w[auto always never],
|
|
77
|
+
"Color: auto (TTY, respects NO_COLOR), always or never.") do |mode|
|
|
78
|
+
@color = mode || "always"
|
|
79
|
+
end
|
|
80
|
+
@parser.on("--no-color", "Plain text with the same page layout.") { @color = "never" }
|
|
81
|
+
@parser.on("--[no-]pager", "Display through a pager (automatically disabled in pipes).") do |value|
|
|
82
|
+
@pager_enabled = value
|
|
83
|
+
@driver_options[:use_stdout] = !value
|
|
84
|
+
end
|
|
85
|
+
@parser.on("-T", "Write directly to stdout.") do
|
|
86
|
+
@pager_enabled = false
|
|
87
|
+
@driver_options[:use_stdout] = true
|
|
88
|
+
end
|
|
89
|
+
@parser.on("-w", "--width=WIDTH", Integer, "Text width in terminal columns (at least 20).") do |width|
|
|
90
|
+
raise OptionParser::InvalidArgument, "width must be at least 20" if width < 20
|
|
91
|
+
|
|
92
|
+
@driver_options[:width] = width
|
|
93
|
+
end
|
|
94
|
+
@parser.on("-f", "--format=NAME", self.class.formats,
|
|
95
|
+
"Select an original RDoc formatter: #{self.class.formats.join(', ')}.") do |name|
|
|
96
|
+
@driver_options[:formatter] = RDoc::Markup.const_get("To#{name.capitalize}")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def lookup_options
|
|
101
|
+
@parser.separator ""
|
|
102
|
+
@parser.separator "Lookup:"
|
|
103
|
+
{ "interactive" => ["-i", :interactive, "Repeated lookup with Tab completion."],
|
|
104
|
+
"all" => ["-a", :show_all, "Include all methods in a class page."],
|
|
105
|
+
"list" => ["-l", :list, "List known classes and modules."] }.each do |name, (short, key, desc)|
|
|
106
|
+
@parser.on(short, "--[no-]#{name}", desc) { |value| @driver_options[key] = value }
|
|
107
|
+
end
|
|
108
|
+
@parser.on("--[no-]expand-refs", "Expand RDoc references at the end of a page.") do |value|
|
|
109
|
+
@driver_options[:expand_refs] = value
|
|
110
|
+
end
|
|
111
|
+
@parser.on("--server[=PORT]", Integer, "Serve RDoc in a browser (port: 8214; requires webrick).") do |port|
|
|
112
|
+
@driver_options[:server] = port || 8214
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def source_options
|
|
117
|
+
@parser.separator ""
|
|
118
|
+
@parser.separator "Documentation sources:"
|
|
119
|
+
@parser.on("-d", "--doc-dir=DIRS", "Read RI stores from these directories; repeatable.") do |value|
|
|
120
|
+
# Prefer an existing literal path, including commas, over RI's list form.
|
|
121
|
+
directories = File.directory?(value) ? [value] : value.split(",")
|
|
122
|
+
directories.each do |dir|
|
|
123
|
+
raise OptionParser::InvalidArgument, "#{dir} is not a directory" unless File.directory?(dir)
|
|
124
|
+
|
|
125
|
+
@driver_options[:extra_doc_dirs] << File.expand_path(dir)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
@parser.on("--no-standard-docs", "Use only directories provided with --doc-dir.") do
|
|
129
|
+
%i[system site home gems].each { |key| @driver_options[:"use_#{key}"] = false }
|
|
130
|
+
end
|
|
131
|
+
%w[system site home gems].each do |source|
|
|
132
|
+
@parser.on("--[no-]#{source}", "Include #{source} documentation (default: enabled).") do |value|
|
|
133
|
+
@driver_options[:"use_#{source}"] = value
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
@parser.on("--[no-]list-doc-dirs", "List the directories searched for RI documentation.") do |value|
|
|
137
|
+
@driver_options[:list_doc_dirs] = value
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def utility_options
|
|
142
|
+
@parser.separator ""
|
|
143
|
+
@parser.separator "Tools:"
|
|
144
|
+
@parser.on("--completion=SHELL", %w[bash zsh fish], "Print a completion script for bash, zsh or fish.") do |shell|
|
|
145
|
+
@action = [:completion, shell]
|
|
146
|
+
end
|
|
147
|
+
@parser.on("--man", "Open the bundled manual with man.") { @action = [:man] }
|
|
148
|
+
@parser.on("--man-path", "Print the path to the bundled manual.") { @action = [:man_path] }
|
|
149
|
+
@parser.on("--install-man[=DIR]", "Install or update the manual in a user man1 directory.") do |directory|
|
|
150
|
+
@action = [:install_man, directory]
|
|
151
|
+
end
|
|
152
|
+
@parser.on("--dump=CACHE", "Inspect a trusted RI cache file.") { |path| @driver_options[:dump_path] = path }
|
|
153
|
+
@parser.on("--[no-]profile", "Run Ruby's profiler (requires the profile gem).") do |value|
|
|
154
|
+
@driver_options[:profile] = value
|
|
155
|
+
end
|
|
156
|
+
@parser.on("-h", "--help", "Show this help.") { @action = [:help] }
|
|
157
|
+
@parser.on("-v", "--version", "Show the rich-ri version.") { @action = [:version] }
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "color"
|
|
4
|
+
|
|
5
|
+
module RichRI
|
|
6
|
+
# Parse a small declarative style language, never arbitrary ANSI sequences.
|
|
7
|
+
class Style
|
|
8
|
+
ATTRIBUTES = { "bold" => "1", "dim" => "2", "italic" => "3", "underline" => "4",
|
|
9
|
+
"reverse" => "7", "strike" => "9" }.freeze
|
|
10
|
+
|
|
11
|
+
def initialize(value)
|
|
12
|
+
unless value.is_a?(String) && !value.empty? && RichRI.sanitize(value) == value
|
|
13
|
+
raise ArgumentError, "style must be a nonempty string without control characters"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
@parts = []
|
|
17
|
+
return if value == "none"
|
|
18
|
+
|
|
19
|
+
seen = {}
|
|
20
|
+
value.split(":", -1).each do |token|
|
|
21
|
+
key, part = parse_token(token)
|
|
22
|
+
raise ArgumentError, "duplicate #{key} in style #{value.inspect}" if seen[key]
|
|
23
|
+
|
|
24
|
+
seen[key] = true
|
|
25
|
+
@parts << [key, part]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def sgr(depth)
|
|
30
|
+
@parts.map do |key, part|
|
|
31
|
+
part.is_a?(Color) ? part.sgr(depth, background: key == "bg") : part
|
|
32
|
+
end.join(";")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def parse_token(token)
|
|
38
|
+
return [token, ATTRIBUTES.fetch(token)] if ATTRIBUTES.key?(token)
|
|
39
|
+
|
|
40
|
+
key, value = token.include?("=") ? token.split("=", 2) : ["fg", token]
|
|
41
|
+
unless %w[fg bg].include?(key)
|
|
42
|
+
raise ArgumentError, "unknown style property #{key.inspect}; use fg, bg or a text attribute"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
[key, Color.new(value)]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|