rfc-reader 1.0.1 → 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f626967d7919c641d14227967a9e863a2c4eaedfd5f97dc5ecde994349725cd5
4
- data.tar.gz: 303d9bf3449ac2122f27f36f3053dfdbb7350b1f8900662517055aadfe8a6c42
3
+ metadata.gz: 9d0a782273a9c8f8accd5e24f7b526c7e12e03f870dec176fa7a48fc74650259
4
+ data.tar.gz: 7c682de619ec580ab71819c50913c609a422696bfb4b64d52b090688fba6773f
5
5
  SHA512:
6
- metadata.gz: 1c29a80e008410ed0924921850c70ad84b8a8bf99e341725a6164757204db8469e7f4eab9c725e04557098251e9d22c23861eb1fbe9a0a9c1cd75603392d9aff
7
- data.tar.gz: 3351a9b4447a38eabf3eb9ba5f0fd50415e9502af6db5fadd236ee1ab848ff57bde6b959b0a6ceaafb337387310f7ca656b9214b7ec063879df51daa275415f7
6
+ metadata.gz: a543898b48200f9f4f1e99a592ffdb903e83957bde3df0c8b8c91fee92f2690927309259524c4ee1eb126e33a79aa0b18ef0bcb463c4ef448a611b67321e69ff
7
+ data.tar.gz: f3e4c4d6f6ba2c4cd4aa5620c7801753c9e1fe4b29adb595014ee27e639e34b7ca37faea7fd79e8c2dc00c7952d60f224d087a85cdbc9e81e1d574c06ccb4c93
data/exe/rfc-reader CHANGED
@@ -4,4 +4,4 @@
4
4
  $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
5
  require "rfc_reader"
6
6
 
7
- exit RfcReader::Command.start
7
+ exit RfcReader.start
@@ -49,10 +49,11 @@ module RfcReader
49
49
  warn "No search results for: #{term}"
50
50
  return FAILURE
51
51
  end
52
- title = Terminal.choose("Choose an RFC to read:", search_results.keys)
53
- url = search_results.fetch(title)
54
- content = Library.download_document(title: title, url: url)
55
- Terminal.page(content)
52
+ Terminal.choose("Choose an RFC to read:", search_results.keys) do |title|
53
+ url = search_results.fetch(title)
54
+ content = Library.download_document(title: title, url: url)
55
+ Terminal.page(content)
56
+ end
56
57
  SUCCESS
57
58
  end
58
59
 
@@ -70,10 +71,11 @@ module RfcReader
70
71
  warn "Error: Empty recent RFC list from rfc-editor.org RSS feed"
71
72
  return FAILURE
72
73
  end
73
- title = Terminal.choose("Choose an RFC to read:", recent_results.keys)
74
- url = recent_results.fetch(title)
75
- content = Library.download_document(title: title, url: url)
76
- Terminal.page(content)
74
+ Terminal.choose("Choose an RFC to read:", recent_results.keys) do |title|
75
+ url = recent_results.fetch(title)
76
+ content = Library.download_document(title: title, url: url)
77
+ Terminal.page(content)
78
+ end
77
79
  SUCCESS
78
80
  end
79
81
 
@@ -95,10 +97,11 @@ module RfcReader
95
97
  return FAILURE
96
98
  end
97
99
  all_titles = rfc_catalog.map { _1[:title] }
98
- title = Terminal.choose("Choose an RFC to read:", all_titles)
99
- rfc = rfc_catalog.find { _1[:title] == title }
100
- content = Library.load_document(**rfc)
101
- Terminal.page(content)
100
+ Terminal.choose("Choose an RFC to read:", all_titles) do |title|
101
+ rfc = rfc_catalog.find { _1[:title] == title }
102
+ content = Library.load_document(**rfc)
103
+ Terminal.page(content)
104
+ end
102
105
  SUCCESS
103
106
  end
104
107
 
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module RfcReader
6
+ module ErrorContext
7
+ class ContextError < StandardError
8
+ extend Forwardable
9
+
10
+ attr_reader :context
11
+
12
+ def_delegators :@cause, :message, :to_s, :backtrace, :backtrace_locations
13
+
14
+ # @param cause [StandardError]
15
+ # @param context [String] (Optional)
16
+ def initialize(cause:, context: nil)
17
+ @cause = cause
18
+ @context = context || default_context
19
+ super
20
+ end
21
+
22
+ def short_message
23
+ "Error: #{context}"
24
+ end
25
+
26
+ def full_message
27
+ <<~MESSAGE
28
+ Context:
29
+ #{indent(@context)}
30
+ Error:
31
+ #{@cause.class}
32
+ Message:
33
+ #{indent(message)}
34
+ Backtrace:
35
+ #{indent(backtrace)}
36
+ MESSAGE
37
+ end
38
+
39
+ private
40
+
41
+ def default_context
42
+ "#{@cause.class}: #{message.lines.first}"
43
+ end
44
+
45
+ def indent(string_or_array)
46
+ strings = case string_or_array
47
+ when Array then string_or_array
48
+ when String then string_or_array.lines
49
+ else raise ArgumentError, "Expected string or array instead of #{string_or_array.class}"
50
+ end
51
+
52
+ strings
53
+ .map { _1.prepend(" ") }
54
+ .join("\n")
55
+ end
56
+ end
57
+
58
+ # Yields an error context. Any `StandardError` that gets raised in this block
59
+ # gets wrapped by `ContextError` automatically and re-raised.
60
+ #
61
+ # If no error has been raised, it returns the result of the block.
62
+ #
63
+ # @param context [String]
64
+ # @return yielded block value
65
+ def self.wrap(context)
66
+ yield
67
+ rescue StandardError => e
68
+ raise ContextError.new(cause: e, context: context)
69
+ end
70
+
71
+ # Yields a handler context where any `StandardError` is caught and the error message is
72
+ # printed to stderr along with the context. It prints only a short message by default
73
+ # and prints the full error message if the `DEBUG` error message is set. It then exits
74
+ # with a non-zero error code.
75
+ #
76
+ # If no error has been raised, it returns the result of the block as an integer.
77
+ # If the result of block cannot be turned into an integer, it returns zero.
78
+ #
79
+ # @return [Integer] exit code
80
+ def self.handler
81
+ error = nil
82
+
83
+ begin
84
+ result = yield
85
+ return result.respond_to?(:to_i) ? result.to_i : 0
86
+ rescue ContextError => e
87
+ error = e
88
+ rescue StandardError => e
89
+ error = ContextError.new(cause: e)
90
+ end
91
+
92
+ if ENV["DEBUG"]
93
+ warn error.full_message
94
+ else
95
+ warn error.short_message
96
+ warn "Note: Set the `DEBUG` environment variable to see the full error context"
97
+ end
98
+
99
+ 1
100
+ end
101
+ end
102
+ end
@@ -16,7 +16,10 @@ module RfcReader
16
16
  file_name = File.basename(url)
17
17
  file_path = File.join(library_cache_dir, file_name)
18
18
 
19
- content = Net::HTTP.get(URI(url))
19
+ content = ErrorContext.wrap("Downloading RFC document") do
20
+ Net::HTTP.get(URI(url))
21
+ end
22
+
20
23
  FileUtils.mkdir_p(library_cache_dir)
21
24
  File.write(file_path, content)
22
25
  add_to_catalog(title: title, url: url, path: file_path)
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "net/http"
4
- require "nokogiri"
4
+ require "rss"
5
5
 
6
6
  module RfcReader
7
7
  module Recent
8
- RECENT_RFCS_RSS_URI = URI("https://www.rfc-editor.org/rfcrss.xml").freeze
8
+ RECENT_RFCS_RSS_URI = URI("https://www.rfc-editor.org/rfcatom.xml").freeze
9
9
  private_constant :RECENT_RFCS_RSS_URI
10
10
 
11
11
  # @return [Hash<String, String>] from RFC title to text file url
@@ -16,7 +16,9 @@ module RfcReader
16
16
 
17
17
  # @return [String] the raw XML from the recent RFCs RSS feed
18
18
  def self.fetch
19
- Net::HTTP.get(RECENT_RFCS_RSS_URI)
19
+ ErrorContext.wrap("Fetching the recent RFCs list") do
20
+ Net::HTTP.get(RECENT_RFCS_RSS_URI)
21
+ end
20
22
  end
21
23
 
22
24
  # Example: XML fragment we're trying to parse title and link data from.
@@ -36,18 +38,16 @@ module RfcReader
36
38
  # @param xml [String] the XML of the recent RFCs RSS endpoint
37
39
  # @return [Hash<String, String>] from RFC title to text file url
38
40
  def self.parse(xml)
39
- Nokogiri::XML(xml).xpath("//item").to_h do |item|
40
- item_hash = item.elements.to_h do |elem|
41
- [elem.name, elem.text.strip]
42
- end
41
+ ErrorContext.wrap("Parsing the recent RFCs list") do
42
+ RSS::Parser.parse(xml).items.to_h do |item|
43
+ # The link is to the webpage and not the plaintext document so we must convert it.
44
+ file_name = File.basename(item.link.href)
43
45
 
44
- # The link is to the webpage and not the plaintext document so we must convert it.
45
- file_name = File.basename(item_hash.fetch("link"))
46
-
47
- [
48
- item_hash.fetch("title"),
49
- "https://www.rfc-editor.org/rfc/#{file_name}.txt",
50
- ]
46
+ [
47
+ item.title.content,
48
+ "https://www.rfc-editor.org/rfc/#{file_name}.txt",
49
+ ]
50
+ end
51
51
  end
52
52
  end
53
53
  end
@@ -18,7 +18,9 @@ module RfcReader
18
18
  # @param term [String]
19
19
  # @return [String] the raw HTML of the search results for the given term
20
20
  def self.fetch_by(term:)
21
- Net::HTTP.post_form(RFC_SEARCH_URI, { combo_box: term }).body
21
+ ErrorContext.wrap("Fetching RFC search results") do
22
+ Net::HTTP.post_form(RFC_SEARCH_URI, { combo_box: term }).body
23
+ end
22
24
  end
23
25
 
24
26
  # Example: HTML fragment we're trying to parse title and link info from.
@@ -71,13 +73,23 @@ module RfcReader
71
73
  # @param html [String] the HTML of the search results
72
74
  # @return [Hash<String, String>] from RFC title to text file url
73
75
  def self.parse(html)
74
- # NOTE: The first element in the table is just some general search information. See example HTML above.
75
- Nokogiri::HTML(html).xpath("//div[@class='scrolltable']//table[@class='gridtable']//tr").drop(1).to_h do |tr_node|
76
- td_nodes = tr_node.elements
77
- title = td_nodes[2].text.strip
78
- url = td_nodes[1].elements.map { _1.attribute("href").text.strip }.find { _1.end_with?(".txt") }
76
+ ErrorContext.wrap("Parsing RFC search results") do
77
+ # NOTE: The first element in the table is just some general search information. See example HTML above.
78
+ Nokogiri::HTML(html)
79
+ .xpath("//div[@class='scrolltable']//table[@class='gridtable']//tr")
80
+ .drop(1)
81
+ .to_h do |tr_node|
82
+ td_nodes = tr_node.elements
83
+ title = td_nodes[2]
84
+ .text
85
+ .strip
86
+ url = td_nodes[1]
87
+ .elements
88
+ .map { _1.attribute("href").text.strip }
89
+ .find { _1.end_with?(".txt") }
79
90
 
80
- [title, url]
91
+ [title, url]
92
+ end
81
93
  end
82
94
  end
83
95
  end
@@ -10,17 +10,75 @@ module RfcReader
10
10
 
11
11
  # @param prompt [String]
12
12
  # @param choices [Array<String>] where all choices are unique
13
- def self.choose(prompt, choices)
14
- require "tty-prompt"
15
- TTY::Prompt
16
- .new(
13
+ # @yield [String] yields until the user exits the prompt gracefully
14
+ def self.choose(message, choices)
15
+ while (choice = selector.select(message, choices))
16
+ yield choice
17
+ end
18
+ end
19
+
20
+ # @return [Selector]
21
+ def self.selector
22
+ @selector ||= Selector.new
23
+ end
24
+ private_class_method :selector
25
+
26
+ # Wrapper around `TTY::Prompt` that adds Vim keybindings and
27
+ # the ability to gracefully exit the prompt.
28
+ class Selector
29
+ def initialize
30
+ require "tty-prompt"
31
+ @prompt = TTY::Prompt.new(
17
32
  quiet: true,
18
33
  track_history: false,
19
34
  interrupt: :exit,
20
35
  symbols: { marker: ">" },
21
36
  enable_color: !ENV["NO_COLOR"]
22
37
  )
23
- .select(prompt, choices)
38
+
39
+ # Indicate user intention to exit
40
+ @exit = false
41
+
42
+ # vim keybindings
43
+ @prompt.on(:keypress) do |event|
44
+ case event.value
45
+ when "j" # Move down
46
+ @prompt.trigger(:keydown)
47
+ when "k" # Move up
48
+ @prompt.trigger(:keyup)
49
+ when "h" # Move left
50
+ @prompt.trigger(:keyleft)
51
+ when "l" # Move right
52
+ @prompt.trigger(:keyright)
53
+ when "q" # Exit
54
+ @exit = true
55
+ @prompt.trigger(:keyenter)
56
+ end
57
+ end
58
+
59
+ # Exit on escape
60
+ @prompt.on(:keyescape) do
61
+ @exit = true
62
+ @prompt.trigger(:keyenter)
63
+ end
64
+ end
65
+
66
+ # @param prompt [String]
67
+ # @param choices [Array<String>] where all choices are unique
68
+ # @return [String|nil]
69
+ def select(message, choices)
70
+ choice = @prompt.select(
71
+ message,
72
+ choices,
73
+ per_page: 15,
74
+ help: "(Press Enter to select and Escape to leave)",
75
+ show_help: :always
76
+ )
77
+ choice unless @exit
78
+ ensure
79
+ @exit = false
80
+ end
24
81
  end
82
+ private_constant :Selector
25
83
  end
26
84
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RfcReader
4
- VERSION = "1.0.1"
4
+ VERSION = "1.2.0"
5
5
  end
data/lib/rfc_reader.rb CHANGED
@@ -8,4 +8,12 @@ module RfcReader
8
8
  autoload :Search, "rfc_reader/search"
9
9
  autoload :Terminal, "rfc_reader/terminal"
10
10
  autoload :Library, "rfc_reader/library"
11
+ autoload :ErrorContext, "rfc_reader/error_context"
12
+
13
+ # @return [Integer] exit code
14
+ def self.start
15
+ ErrorContext.handler do
16
+ Command.start
17
+ end
18
+ end
11
19
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rfc-reader
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kevin Robell
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-09-28 00:00:00.000000000 Z
10
+ date: 2025-01-20 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: nokogiri
@@ -24,6 +23,20 @@ dependencies:
24
23
  - - "~>"
25
24
  - !ruby/object:Gem::Version
26
25
  version: '1.16'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rss
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.3'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.3'
27
40
  - !ruby/object:Gem::Dependency
28
41
  name: thor
29
42
  requirement: !ruby/object:Gem::Requirement
@@ -66,7 +79,6 @@ dependencies:
66
79
  - - "~>"
67
80
  - !ruby/object:Gem::Version
68
81
  version: '0.23'
69
- description:
70
82
  email:
71
83
  - apainintheneck@gmail.com
72
84
  executables:
@@ -77,6 +89,7 @@ files:
77
89
  - exe/rfc-reader
78
90
  - lib/rfc_reader.rb
79
91
  - lib/rfc_reader/command.rb
92
+ - lib/rfc_reader/error_context.rb
80
93
  - lib/rfc_reader/library.rb
81
94
  - lib/rfc_reader/recent.rb
82
95
  - lib/rfc_reader/search.rb
@@ -90,7 +103,6 @@ metadata:
90
103
  source_code_uri: https://github.com/apainintheneck/rfc-reader/
91
104
  changelog_uri: https://github.com/apainintheneck/rfc-reader/blob/main/CHANELOG.md
92
105
  rubygems_mfa_required: 'true'
93
- post_install_message:
94
106
  rdoc_options: []
95
107
  require_paths:
96
108
  - lib
@@ -105,8 +117,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
105
117
  - !ruby/object:Gem::Version
106
118
  version: '0'
107
119
  requirements: []
108
- rubygems_version: 3.5.6
109
- signing_key:
120
+ rubygems_version: 3.6.3
110
121
  specification_version: 4
111
122
  summary: Search for and read RFCs at the command line.
112
123
  test_files: []