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