bitclust-core 1.6.1 → 1.8.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 +4 -4
- data/Rakefile +1 -0
- data/data/bitclust/catalog/ja_JP.UTF-8 +4 -2
- data/data/bitclust/template/class-index +0 -1
- data/data/bitclust/template/function-index +1 -0
- data/data/bitclust/template/layout +1 -0
- data/data/bitclust/template.epub/class +5 -5
- data/data/bitclust/template.epub/class-index +0 -1
- data/data/bitclust/template.epub/contents +7 -4
- data/data/bitclust/template.epub/layout +4 -4
- data/data/bitclust/template.offline/class-index +0 -1
- data/data/bitclust/template.offline/function-index +1 -0
- data/data/bitclust/template.offline/layout +3 -0
- data/lib/bitclust/classentry.rb +1 -1
- data/lib/bitclust/docentry.rb +1 -1
- data/lib/bitclust/functionentry.rb +1 -1
- data/lib/bitclust/generators/epub.rb +69 -5
- data/lib/bitclust/irb.rb +74 -0
- data/lib/bitclust/markdown_bridge.rb +11 -6
- data/lib/bitclust/markdown_exporter.rb +311 -0
- data/lib/bitclust/markdown_tree.rb +118 -11
- data/lib/bitclust/mdcompiler.rb +15 -1
- data/lib/bitclust/mdparser.rb +42 -1
- data/lib/bitclust/methoddatabase.rb +17 -4
- data/lib/bitclust/methodentry.rb +24 -0
- data/lib/bitclust/methodsignature.rb +1 -1
- data/lib/bitclust/nameutils.rb +13 -0
- data/lib/bitclust/preprocessor.rb +4 -37
- data/lib/bitclust/rbs_overload_matcher.rb +179 -0
- data/lib/bitclust/rbs_sig_importer.rb +276 -0
- data/lib/bitclust/rbs_signatures.rb +125 -0
- data/lib/bitclust/rdcompiler.rb +12 -1
- data/lib/bitclust/remote.rb +405 -0
- data/lib/bitclust/runner.rb +7 -10
- data/lib/bitclust/screen.rb +6 -1
- data/lib/bitclust/search_index_generator.rb +28 -10
- data/lib/bitclust/searcher.rb +54 -25
- data/lib/bitclust/subcommands/chm_command.rb +1 -1
- data/lib/bitclust/subcommands/epub_command.rb +2 -2
- data/lib/bitclust/subcommands/extract_command.rb +7 -0
- data/lib/bitclust/subcommands/htmlfile_command.rb +7 -0
- data/lib/bitclust/subcommands/methods_command.rb +5 -28
- data/lib/bitclust/subcommands/query_command.rb +1 -0
- data/lib/bitclust/subcommands/rbssig_command.rb +99 -0
- data/lib/bitclust/subcommands/server_command.rb +21 -10
- data/lib/bitclust/subcommands/setup_command.rb +32 -11
- data/lib/bitclust/subcommands/statichtml_command.rb +59 -0
- data/lib/bitclust/syntax_highlighter.rb +65 -4
- data/lib/bitclust/user_dirs.rb +62 -0
- data/lib/bitclust/version.rb +1 -1
- data/lib/bitclust-irb.rb +2 -0
- data/test/test_chm_command.rb +22 -0
- data/test/test_epub_command.rb +60 -0
- data/test/test_epub_generator.rb +330 -0
- data/test/test_extract_command.rb +45 -0
- data/test/test_irb_plugin.rb +212 -0
- data/test/test_link_checker.rb +1 -1
- data/test/test_markdown_bridge.rb +35 -0
- data/test/test_markdown_exporter.rb +260 -0
- data/test/test_markdown_tree.rb +144 -0
- data/test/test_mdcompiler.rb +47 -5
- data/test/test_mdparser.rb +223 -4
- data/test/test_methodentry.rb +111 -0
- data/test/test_methods_command.rb +32 -0
- data/test/test_methodsignature.rb +3 -1
- data/test/test_opensearchdescription_screen.rb +39 -0
- data/test/test_preprocessor.rb +26 -0
- data/test/test_query_command.rb +30 -0
- data/test/test_rbs_overload_matcher.rb +170 -0
- data/test/test_rbs_sig_importer.rb +237 -0
- data/test/test_rbssig_command.rb +186 -0
- data/test/test_rdcompiler.rb +99 -1
- data/test/test_remote.rb +374 -0
- data/test/test_runner.rb +61 -1
- data/test/test_search_index_generator.rb +81 -0
- data/test/test_searcher.rb +63 -0
- data/test/test_server_command.rb +41 -0
- data/test/test_setup_command.rb +164 -0
- data/test/test_statichtml_command.rb +137 -0
- data/test/test_syntax_highlighter.rb +124 -0
- data/test/test_user_dirs.rb +62 -0
- data/theme/default/js/run.js +14 -0
- data/theme/default/js/search_init.js +3 -1
- data/theme/default/js/search_page.js +7 -1
- data/theme/default/js/version_switcher.js +147 -0
- data/theme/default/search.css +21 -0
- data/theme/default/style.css +83 -7
- data/theme/lillia/style.css +26 -0
- metadata +71 -2
- data/lib/bitclust/ridatabase.rb +0 -359
data/lib/bitclust/rdcompiler.rb
CHANGED
|
@@ -15,6 +15,7 @@ require 'bitclust/textutils'
|
|
|
15
15
|
require 'bitclust/messagecatalog'
|
|
16
16
|
require 'bitclust/syntax_highlighter'
|
|
17
17
|
require 'bitclust/version_badges'
|
|
18
|
+
require 'bitclust/rbs_signatures'
|
|
18
19
|
require 'rouge'
|
|
19
20
|
require 'stringio'
|
|
20
21
|
|
|
@@ -27,6 +28,7 @@ module BitClust
|
|
|
27
28
|
include TextUtils
|
|
28
29
|
include Translatable
|
|
29
30
|
include VersionBadges
|
|
31
|
+
include RbsSignatures
|
|
30
32
|
|
|
31
33
|
def initialize(urlmapper, hlevel = 1, opt = {})
|
|
32
34
|
@urlmapper = urlmapper
|
|
@@ -59,7 +61,9 @@ module BitClust
|
|
|
59
61
|
@opt = opt
|
|
60
62
|
@type = :method
|
|
61
63
|
@method = m
|
|
62
|
-
|
|
64
|
+
source = m.source
|
|
65
|
+
setup(source, m) {
|
|
66
|
+
prepare_rbs_signatures(m, source)
|
|
63
67
|
entry
|
|
64
68
|
}
|
|
65
69
|
ensure
|
|
@@ -71,6 +75,7 @@ module BitClust
|
|
|
71
75
|
def setup(src, entry = nil)
|
|
72
76
|
@f = LineInput.new(StringIO.new(src), entry)
|
|
73
77
|
@out = StringIO.new
|
|
78
|
+
reset_rbs_signatures
|
|
74
79
|
yield
|
|
75
80
|
@out.string
|
|
76
81
|
end
|
|
@@ -135,6 +140,12 @@ module BitClust
|
|
|
135
140
|
k, v = line.sub(/\A:/, '').split(':', 2)
|
|
136
141
|
props[k&.strip] = v&.strip
|
|
137
142
|
end if @type == :method
|
|
143
|
+
# RBS シグネチャは見出し <dt> 群の直後・説明 <dd> の直前に、この
|
|
144
|
+
# チャンクへ振り分けられたぶんだけ <dt> 行として出す(rbs_sig
|
|
145
|
+
# property が無ければ何も出ない)
|
|
146
|
+
if (rbs_dts = rbs_signature_dts_for_chunk())
|
|
147
|
+
@out.puts rbs_dts
|
|
148
|
+
end
|
|
138
149
|
@out.puts %Q(<dd class="#{@type.to_s}-description">)
|
|
139
150
|
undef_message if attrs.include?('undef')
|
|
140
151
|
while @f.next?
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# ローカル DB が無い環境向けのリモート検索。docs.ruby-lang.org の検索索引
|
|
4
|
+
# (js/search_data.js = SearchIndexGenerator の生成物)を refe と同じ規則で
|
|
5
|
+
# 引いて候補を絞り、1 件に決まれば Markdown 配信(/ja/latest/ 配下の .md =
|
|
6
|
+
# statichtml --markdown-output の生成物)から本文を取得して表示する。
|
|
7
|
+
# 取得したものは UserDirs.cache_home 配下にキャッシュし、1 日以内はそのまま、
|
|
8
|
+
# それより古ければ If-Modified-Since で確認してから使う。接続できなければ
|
|
9
|
+
# 古いキャッシュで動く。名前一覧の表示には searcher.rb の TerminalView を
|
|
10
|
+
# 使う(このファイルは searcher.rb から require される)。
|
|
11
|
+
|
|
12
|
+
require 'json'
|
|
13
|
+
require 'net/http'
|
|
14
|
+
require 'openssl'
|
|
15
|
+
require 'time'
|
|
16
|
+
require 'uri'
|
|
17
|
+
require 'fileutils'
|
|
18
|
+
require 'bitclust/version'
|
|
19
|
+
require 'bitclust/nameutils'
|
|
20
|
+
require 'bitclust/exception'
|
|
21
|
+
require 'bitclust/user_dirs'
|
|
22
|
+
|
|
23
|
+
module BitClust
|
|
24
|
+
class Remote
|
|
25
|
+
include NameUtils
|
|
26
|
+
|
|
27
|
+
DEFAULT_BASE_URL = 'https://docs.ruby-lang.org/ja/latest/'
|
|
28
|
+
SEARCH_URL = 'https://docs.ruby-lang.org/ja/search/'
|
|
29
|
+
INDEX_PATH = 'js/search_data.js'
|
|
30
|
+
ENV_KEY = 'BITCLUST_REMOTE_URL'
|
|
31
|
+
|
|
32
|
+
# 200/304/404 以外の応答(キャッシュも無いとき)
|
|
33
|
+
class HTTPError < StandardError; end
|
|
34
|
+
|
|
35
|
+
NETWORK_ERRORS = [SocketError, IOError, SystemCallError, Timeout::Error,
|
|
36
|
+
OpenSSL::SSL::SSLError, Net::ProtocolError, HTTPError].freeze
|
|
37
|
+
|
|
38
|
+
# BITCLUST_REMOTE_URL にこの値(大小無視)を設定するとリモート検索をしない。
|
|
39
|
+
# 空文字列でも同じだが、Windows のシェル(cmd.exe・PowerShell)では
|
|
40
|
+
# 空の環境変数を作れない(削除になる)ので、無効化の値を別に用意する
|
|
41
|
+
DISABLED_VALUE = 'none'
|
|
42
|
+
|
|
43
|
+
# 既定の取得元。環境変数 BITCLUST_REMOTE_URL があればそれを使い、
|
|
44
|
+
# DISABLED_VALUE か空文字列ならリモート検索をしない(nil)
|
|
45
|
+
def self.default
|
|
46
|
+
url = ENV.fetch(ENV_KEY, DEFAULT_BASE_URL)
|
|
47
|
+
url.empty? || url.casecmp?(DISABLED_VALUE) ? nil : new(base_url: url)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
attr_reader :base_url
|
|
51
|
+
|
|
52
|
+
# fetcher は (URI, ヘッダの Hash) を受け取って [ステータス, 本文] を返す
|
|
53
|
+
# 呼び出し可能オブジェクト。省略時は Net::HTTP。cache_dir はキャッシュの
|
|
54
|
+
# 置き場所、clock は現在時刻を返す Proc(テスト用)
|
|
55
|
+
def initialize(base_url: DEFAULT_BASE_URL, fetcher: nil,
|
|
56
|
+
cache_dir: UserDirs.cache_home, clock: nil)
|
|
57
|
+
@base_url = base_url.end_with?('/') ? base_url : "#{base_url}/"
|
|
58
|
+
clock ||= -> { Time.now }
|
|
59
|
+
if fetcher
|
|
60
|
+
@http = nil
|
|
61
|
+
@cache = Cache.new(dir: cache_dir.to_s, fetcher: fetcher, clock: clock)
|
|
62
|
+
else
|
|
63
|
+
http = HttpFetcher.new
|
|
64
|
+
@http = http
|
|
65
|
+
@cache = Cache.new(dir: cache_dir.to_s, fetcher: http, clock: clock)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# refe と同じ引数 words で検索して結果を io へ書く。何か表示できれば true。
|
|
70
|
+
# 見つからない・取得できない場合はメッセージを書いて false(例外にしない)
|
|
71
|
+
def lookup(words, io, describe_all: false, line: false, class_only: false)
|
|
72
|
+
entries = load_index.search(words, class_only: class_only).sort_by(&:full_name)
|
|
73
|
+
if entries.empty?
|
|
74
|
+
io.puts not_found_message(words, class_only)
|
|
75
|
+
io.puts "検索ページ: #{search_url(words)}" unless words.empty?
|
|
76
|
+
return false
|
|
77
|
+
end
|
|
78
|
+
view = TerminalView.new(Plain.new, { describe_all: describe_all, line: line, encoding: nil }, io: io)
|
|
79
|
+
if line || (entries.size > 1 && !describe_all)
|
|
80
|
+
view.print_names(entries.map(&:full_name))
|
|
81
|
+
else
|
|
82
|
+
entries.each {|e| show_page(e, io) }
|
|
83
|
+
io.puts '(docs.ruby-lang.org から取得しました。手元で検索するには bitclust setup で DB を作成してください)'
|
|
84
|
+
end
|
|
85
|
+
true
|
|
86
|
+
rescue *NETWORK_ERRORS => err
|
|
87
|
+
io.puts "docs.ruby-lang.org に接続できず、キャッシュもありません (#{err.class}: #{err.message})"
|
|
88
|
+
io.puts 'オフラインで使うには bitclust setup で DB を作成してください。'
|
|
89
|
+
false
|
|
90
|
+
ensure
|
|
91
|
+
@http&.close
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def search_url(words)
|
|
95
|
+
"#{SEARCH_URL}?q=#{URI.encode_www_form_component(words.join(' '))}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# 同じキャッシュファイル(パスと mtime)なら解析済みの索引を使い回す
|
|
99
|
+
def self.index_for(path, body)
|
|
100
|
+
key = [path, File.mtime(path)]
|
|
101
|
+
memo = @index_memo
|
|
102
|
+
return memo[1] if memo && memo[0] == key
|
|
103
|
+
index = Index.parse(body)
|
|
104
|
+
@index_memo = [key, index]
|
|
105
|
+
index
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def load_index
|
|
111
|
+
hit = @cache.fetch(URI("#{@base_url}#{INDEX_PATH}"))
|
|
112
|
+
raise HTTPError, "#{INDEX_PATH} not found" unless hit
|
|
113
|
+
self.class.index_for(hit.path, hit.body)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def show_page(entry, io)
|
|
117
|
+
html_url = "#{@base_url}#{entry.path}"
|
|
118
|
+
hit = @cache.fetch(URI("#{@base_url}#{entry.path.sub(/\.html\z/, '.md')}"))
|
|
119
|
+
if hit
|
|
120
|
+
io.puts hit.body
|
|
121
|
+
io.puts
|
|
122
|
+
io.puts '---'
|
|
123
|
+
io.puts html_url
|
|
124
|
+
if hit.stale
|
|
125
|
+
io.puts "(オフライン: #{File.mtime(hit.path).strftime('%Y-%m-%d %H:%M')} 取得のキャッシュを表示しています)"
|
|
126
|
+
end
|
|
127
|
+
else
|
|
128
|
+
io.puts "#{entry.full_name}: ページを取得できませんでした (#{html_url})"
|
|
129
|
+
end
|
|
130
|
+
io.puts
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def not_found_message(words, class_only)
|
|
134
|
+
pattern = words.join(' ')
|
|
135
|
+
if class_only
|
|
136
|
+
"no such class: #{pattern}"
|
|
137
|
+
elsif words.size >= 2
|
|
138
|
+
"no such method in #{words.first}: #{words.last}"
|
|
139
|
+
elsif pattern.match?(/[\#,]\.|\.[\#,]|[\#\.\,]/)
|
|
140
|
+
"no such method: #{pattern}"
|
|
141
|
+
else
|
|
142
|
+
"no such class or method: #{pattern}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# 索引の 1 エントリ。メソッド系(instance_method・class_method・constant・
|
|
147
|
+
# variable)は path("method/<クラス>/<種別 1 文字>/<名前>.html")から
|
|
148
|
+
# klass と typechar を持つ
|
|
149
|
+
class Entry
|
|
150
|
+
attr_reader :name, :full_name, :type, :path
|
|
151
|
+
attr_accessor :klass, :typechar
|
|
152
|
+
|
|
153
|
+
def initialize(name:, full_name:, type:, path:, klass: nil, typechar: nil)
|
|
154
|
+
@name = name
|
|
155
|
+
@full_name = full_name
|
|
156
|
+
@type = type
|
|
157
|
+
@path = path
|
|
158
|
+
@klass = klass
|
|
159
|
+
@typechar = typechar
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# search_data.js を読んで refe と同じ規則で検索する
|
|
164
|
+
class Index
|
|
165
|
+
include NameUtils
|
|
166
|
+
|
|
167
|
+
CLASS_TYPES = %w[class module object].freeze
|
|
168
|
+
METHOD_TYPES = %w[instance_method class_method constant variable].freeze
|
|
169
|
+
METHOD_PATH_RE = %r{\Amethod/([^/]+)/([a-z])/([^/]+)\.html\z}
|
|
170
|
+
|
|
171
|
+
def self.parse(js)
|
|
172
|
+
json = js.sub(/\A\s*var\s+search_data\s*=\s*/, '').sub(/;\s*\z/, '')
|
|
173
|
+
data = JSON.parse(json)
|
|
174
|
+
entries = data['index'].map {|h|
|
|
175
|
+
entry = Entry.new(name: h['name'], full_name: h['full_name'], type: h['type'], path: h['path'])
|
|
176
|
+
if METHOD_TYPES.include?(entry.type) && (m = METHOD_PATH_RE.match(entry.path))
|
|
177
|
+
entry.klass = NameUtils.decodename_url(m[1] || raise)
|
|
178
|
+
entry.typechar = m[2]
|
|
179
|
+
end
|
|
180
|
+
entry
|
|
181
|
+
}
|
|
182
|
+
new(entries)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def initialize(entries)
|
|
186
|
+
@classes = entries.select {|e| CLASS_TYPES.include?(e.type) }
|
|
187
|
+
@methods = entries.select {|e| METHOD_TYPES.include?(e.type) && e.klass }
|
|
188
|
+
@libraries = entries.select {|e| e.type == 'library' }
|
|
189
|
+
@functions = entries.select {|e| e.type == 'function' }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# words は refe の引数(0〜3 語)。Searcher#search_pattern と同じ分岐
|
|
193
|
+
def search(words, class_only: false)
|
|
194
|
+
if class_only
|
|
195
|
+
raise InvalidKey, '--class option requires only 1 argument' unless words.size == 1
|
|
196
|
+
return narrow(@classes, words[0] || raise, &:full_name)
|
|
197
|
+
end
|
|
198
|
+
case words.size
|
|
199
|
+
when 0
|
|
200
|
+
@classes.dup
|
|
201
|
+
when 1
|
|
202
|
+
search_one(words[0] || raise)
|
|
203
|
+
when 2
|
|
204
|
+
search_method(words[0] || raise, nil, words[1] || raise)
|
|
205
|
+
when 3
|
|
206
|
+
c, t, m = words[0] || raise, words[1] || raise, words[2] || raise
|
|
207
|
+
raise InvalidKey, "'$' cannot be used as method type" if t == '$'
|
|
208
|
+
raise InvalidKey, "unknown method type: #{t.inspect}" unless typemark?(t)
|
|
209
|
+
search_method(c, t, m)
|
|
210
|
+
else
|
|
211
|
+
raise InvalidKey, "too many arguments (#{words.size} for 3)"
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private
|
|
216
|
+
|
|
217
|
+
# Searcher#find_class_or_method と同じ優先順位。ライブラリと C の関数は
|
|
218
|
+
# refe のローカル検索には無い追加の fallback
|
|
219
|
+
def search_one(pat)
|
|
220
|
+
case pat
|
|
221
|
+
when /\A\$/ # 特殊変数
|
|
222
|
+
narrow(@methods.select {|e| e.typechar == 'v' }, pat, &:name)
|
|
223
|
+
when /[\#,]\.|\.[\#,]|[\#\.\,]/ # メソッド指定
|
|
224
|
+
c, t, m = parse_method_spec_pattern(pat)
|
|
225
|
+
search_method(c, t, m)
|
|
226
|
+
when /::/ # クラス名か定数名
|
|
227
|
+
found = narrow(@classes, pat, &:full_name)
|
|
228
|
+
return found unless found.empty?
|
|
229
|
+
names = pat.split('::')
|
|
230
|
+
name = names.pop || raise
|
|
231
|
+
search_method(names.join('::'), '::', name)
|
|
232
|
+
when /\A[A-Z]/ # クラス名が優先
|
|
233
|
+
found = narrow(@classes, pat, &:full_name)
|
|
234
|
+
found = search_method(nil, nil, pat) if found.empty?
|
|
235
|
+
found
|
|
236
|
+
else
|
|
237
|
+
found = search_method(nil, nil, pat)
|
|
238
|
+
found = narrow(@classes, pat, &:full_name) if found.empty?
|
|
239
|
+
found = narrow(@libraries, pat, &:full_name) if found.empty?
|
|
240
|
+
found = narrow(@functions, pat, &:full_name) if found.empty?
|
|
241
|
+
found
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def search_method(c, t, m)
|
|
246
|
+
entries = @methods
|
|
247
|
+
if c
|
|
248
|
+
klasses = narrow(@classes, c, &:full_name).map(&:full_name)
|
|
249
|
+
return [] if klasses.empty?
|
|
250
|
+
entries = entries.select {|e| (k = e.klass) && klasses.include?(k) }
|
|
251
|
+
end
|
|
252
|
+
if t
|
|
253
|
+
typechar = typemark2char(_ = t)
|
|
254
|
+
entries = entries.select {|e| e.typechar == typechar }
|
|
255
|
+
end
|
|
256
|
+
narrow(entries, m, &:name)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Completion#expand と同じ絞り込み: 大小無視の前方一致 → 大小無視の
|
|
260
|
+
# 完全一致 → 完全一致。絞れなければ手前の結果を返す
|
|
261
|
+
def narrow(xs, pattern)
|
|
262
|
+
re1 = /\A#{Regexp.quote(pattern)}/i
|
|
263
|
+
result1 = xs.select {|x| yield(x).match?(re1) }
|
|
264
|
+
return result1 if result1.size <= 1
|
|
265
|
+
re2 = /\A#{Regexp.quote(pattern)}\z/i
|
|
266
|
+
result2 = result1.select {|x| yield(x).match?(re2) }
|
|
267
|
+
return result1 if result2.empty?
|
|
268
|
+
return result2 if result2.size == 1
|
|
269
|
+
result3 = result2.select {|x| yield(x) == pattern }
|
|
270
|
+
result3.empty? ? result2 : result3
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# URL ごとのファイルキャッシュ(dir/<ホスト>/<パス>)。mtime は最後に
|
|
275
|
+
# 確認した時刻。max_age 以内ならそのまま使い、古ければ If-Modified-Since
|
|
276
|
+
# で確認する(304 なら touch)。取得できなければ古いものを stale として返す
|
|
277
|
+
class Cache
|
|
278
|
+
MAX_AGE = 24 * 60 * 60
|
|
279
|
+
|
|
280
|
+
# 取得結果。stale は接続できずに古いキャッシュを返したとき true
|
|
281
|
+
class Hit
|
|
282
|
+
attr_reader :body, :path, :stale
|
|
283
|
+
|
|
284
|
+
def initialize(body:, path:, stale:)
|
|
285
|
+
@body = body
|
|
286
|
+
@path = path
|
|
287
|
+
@stale = stale
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def initialize(dir:, fetcher:, max_age: MAX_AGE, clock: -> { Time.now })
|
|
292
|
+
@dir = dir
|
|
293
|
+
@fetcher = fetcher
|
|
294
|
+
@max_age = max_age
|
|
295
|
+
@clock = clock
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# 本文を Hit で返す。404 なら nil。キャッシュが無くて取得にも失敗したら
|
|
299
|
+
# 例外(Remote::NETWORK_ERRORS のどれか)
|
|
300
|
+
def fetch(uri)
|
|
301
|
+
path = path_for(uri)
|
|
302
|
+
now = @clock.call
|
|
303
|
+
cached = File.file?(path)
|
|
304
|
+
if cached
|
|
305
|
+
mtime = File.mtime(path)
|
|
306
|
+
return Hit.new(body: read(path), path: path, stale: false) if mtime > now - @max_age
|
|
307
|
+
headers = { 'If-Modified-Since' => mtime.httpdate }
|
|
308
|
+
else
|
|
309
|
+
headers = {} #: Hash[String, String]
|
|
310
|
+
end
|
|
311
|
+
begin
|
|
312
|
+
status, body = @fetcher.call(uri, headers)
|
|
313
|
+
rescue *NETWORK_ERRORS
|
|
314
|
+
raise unless cached
|
|
315
|
+
return Hit.new(body: read(path), path: path, stale: true)
|
|
316
|
+
end
|
|
317
|
+
case status
|
|
318
|
+
when 200
|
|
319
|
+
text = to_utf8(body)
|
|
320
|
+
write(path, text, now)
|
|
321
|
+
Hit.new(body: text, path: path, stale: false)
|
|
322
|
+
when 304
|
|
323
|
+
raise HTTPError, "304 without a cached copy: #{uri}" unless cached
|
|
324
|
+
File.utime(now, now, path)
|
|
325
|
+
Hit.new(body: read(path), path: path, stale: false)
|
|
326
|
+
when 404
|
|
327
|
+
File.delete(path) if cached
|
|
328
|
+
nil
|
|
329
|
+
else
|
|
330
|
+
return Hit.new(body: read(path), path: path, stale: true) if cached
|
|
331
|
+
raise HTTPError, "HTTP #{status}: #{uri}"
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
private
|
|
336
|
+
|
|
337
|
+
def path_for(uri)
|
|
338
|
+
File.join(@dir, uri.host || raise, uri.path || raise)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def read(path)
|
|
342
|
+
File.read(path, encoding: Encoding::UTF_8)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def write(path, text, now)
|
|
346
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
347
|
+
tmp = "#{path}.tmp"
|
|
348
|
+
File.binwrite(tmp, text)
|
|
349
|
+
File.rename(tmp, path)
|
|
350
|
+
File.utime(now, now, path)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# 配信は text/markdown; charset=utf-8。Net::HTTP の本文は BINARY で返る
|
|
354
|
+
def to_utf8(body)
|
|
355
|
+
str = body.dup.force_encoding(Encoding::UTF_8)
|
|
356
|
+
str.valid_encoding? ? str : str.scrub
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Net::HTTP による既定の取得。同じホストへの接続は使い回し、close で閉じる。
|
|
361
|
+
# gzip は Net::HTTP が自動で展開する(search_data.js は約 3MB・gzip 420KB)
|
|
362
|
+
class HttpFetcher
|
|
363
|
+
HEADERS = {
|
|
364
|
+
'User-Agent' => "bitclust/#{BitClust::VERSION}",
|
|
365
|
+
'Accept' => 'text/markdown, text/javascript, */*',
|
|
366
|
+
}.freeze
|
|
367
|
+
MAX_REDIRECTS = 2
|
|
368
|
+
|
|
369
|
+
def initialize(open_timeout: 5, read_timeout: 30)
|
|
370
|
+
@open_timeout = open_timeout
|
|
371
|
+
@read_timeout = read_timeout
|
|
372
|
+
@sessions = {}
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def call(uri, headers = {})
|
|
376
|
+
redirects = 0
|
|
377
|
+
loop do
|
|
378
|
+
res = session(uri).get(uri.path || raise, HEADERS.merge(headers))
|
|
379
|
+
location = res['location']
|
|
380
|
+
if res.is_a?(Net::HTTPRedirection) && location && redirects < MAX_REDIRECTS
|
|
381
|
+
redirects += 1
|
|
382
|
+
uri = URI.join(uri, location)
|
|
383
|
+
else
|
|
384
|
+
return [res.code.to_i, res.body.to_s]
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def close
|
|
390
|
+
@sessions.each_value {|http| http.finish if http.started? }
|
|
391
|
+
@sessions.clear
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
private
|
|
395
|
+
|
|
396
|
+
def session(uri)
|
|
397
|
+
@sessions[[uri.host, uri.port]] ||=
|
|
398
|
+
Net::HTTP.start(uri.host || raise, uri.port,
|
|
399
|
+
use_ssl: uri.scheme == 'https',
|
|
400
|
+
open_timeout: @open_timeout,
|
|
401
|
+
read_timeout: @read_timeout)
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
end
|
data/lib/bitclust/runner.rb
CHANGED
|
@@ -14,6 +14,7 @@ $LOAD_PATH.unshift(libdir.to_s)
|
|
|
14
14
|
|
|
15
15
|
require 'bitclust'
|
|
16
16
|
require 'bitclust/subcommand'
|
|
17
|
+
require 'bitclust/user_dirs'
|
|
17
18
|
|
|
18
19
|
subcommands_dir = libdir + "bitclust/subcommands"
|
|
19
20
|
Dir.glob(File.join(subcommands_dir.to_s, "*.rb")) do |entry|
|
|
@@ -62,6 +63,7 @@ Subcommands(for developers):
|
|
|
62
63
|
classes Display defined classes for all ruby.
|
|
63
64
|
methods Display defined methods for all ruby.
|
|
64
65
|
methodsince Fill per-name since/until from a version ladder of DBs.
|
|
66
|
+
rbssig Fill RBS type signatures into a 4.0+ DB from .rbs files.
|
|
65
67
|
checklink Report broken [[c:]]/[[m:]]/[[lib:]]/[[d:]]/[[f:]] links.
|
|
66
68
|
|
|
67
69
|
Subcommands(for packagers):
|
|
@@ -110,6 +112,7 @@ Global Options:
|
|
|
110
112
|
'classes' => BitClust::Subcommands::ClassesCommand.new,
|
|
111
113
|
'methods' => BitClust::Subcommands::MethodsCommand.new,
|
|
112
114
|
'methodsince' => BitClust::Subcommands::MethodsinceCommand.new,
|
|
115
|
+
'rbssig' => BitClust::Subcommands::RbssigCommand.new,
|
|
113
116
|
'checklink' => BitClust::Subcommands::ChecklinkCommand.new,
|
|
114
117
|
}
|
|
115
118
|
end
|
|
@@ -141,12 +144,12 @@ Global Options:
|
|
|
141
144
|
@version ||= config[:default_version]
|
|
142
145
|
@prefix ||= "#{config[:database_prefix]}-#{@version}"
|
|
143
146
|
end
|
|
144
|
-
# DB 必須のサブコマンドで --database も
|
|
145
|
-
#
|
|
147
|
+
# DB 必須のサブコマンドで --database も設定ファイル(UserDirs)も
|
|
148
|
+
# 無ければ案内付きで中断する。needs_database? が false のサブコマンドと、
|
|
146
149
|
# 自前で DB を探す search (Searcher) は対象外
|
|
147
150
|
needs_database = cmd.respond_to?(:needs_database?) && cmd.needs_database?
|
|
148
151
|
if needs_database && !@prefix
|
|
149
|
-
error "no database given. Use --database (-d) option or
|
|
152
|
+
error "no database given. Use --database (-d) option or run `bitclust setup` (config: #{UserDirs.config_candidates.join(' or ')})"
|
|
150
153
|
end
|
|
151
154
|
# @type var options: Subcommand::options
|
|
152
155
|
options = {
|
|
@@ -160,13 +163,7 @@ Global Options:
|
|
|
160
163
|
end
|
|
161
164
|
|
|
162
165
|
def load_config
|
|
163
|
-
|
|
164
|
-
config_path = home_directory + ".bitclust/config"
|
|
165
|
-
if config_path.exist?
|
|
166
|
-
YAML.load_file(config_path)
|
|
167
|
-
else
|
|
168
|
-
nil
|
|
169
|
-
end
|
|
166
|
+
UserDirs.load_config
|
|
170
167
|
end
|
|
171
168
|
|
|
172
169
|
def error(message)
|
data/lib/bitclust/screen.rb
CHANGED
|
@@ -403,6 +403,10 @@ module BitClust
|
|
|
403
403
|
@urlmapper.search_url
|
|
404
404
|
end
|
|
405
405
|
|
|
406
|
+
def document_url(name)
|
|
407
|
+
@urlmapper.document_url(name)
|
|
408
|
+
end
|
|
409
|
+
|
|
406
410
|
def library_index_url
|
|
407
411
|
@urlmapper.library_index_url
|
|
408
412
|
end
|
|
@@ -650,7 +654,8 @@ module BitClust
|
|
|
650
654
|
attr_reader :search_full_url
|
|
651
655
|
|
|
652
656
|
def body
|
|
653
|
-
|
|
657
|
+
# XML なので HTML の layout は通さない
|
|
658
|
+
run_template('opensearchdescription', false)
|
|
654
659
|
end
|
|
655
660
|
|
|
656
661
|
def content_type
|
|
@@ -114,6 +114,9 @@ module BitClust
|
|
|
114
114
|
key = e.values_at(:name, :full_name, :type, :path)
|
|
115
115
|
entry = (merged[key] ||= e.merge(versions: []))
|
|
116
116
|
entry[:versions] << version
|
|
117
|
+
# 説明文は「最新版か、収録されている最終版」のものを採用する。
|
|
118
|
+
# 昇順走査なので後勝ちで上書きすればよい
|
|
119
|
+
entry[:snippet] = e[:snippet] if e[:snippet]
|
|
117
120
|
end
|
|
118
121
|
end
|
|
119
122
|
merged.values
|
|
@@ -126,14 +129,29 @@ module BitClust
|
|
|
126
129
|
|
|
127
130
|
private
|
|
128
131
|
|
|
132
|
+
# 検索結果に添える説明文。meta description と同じ entry.description
|
|
133
|
+
# (最初の説明段落のプレーンテキスト)を使い、空なら nil(キー自体を
|
|
134
|
+
# 持たせない)。プレーンテキストのまま格納し、HTML エスケープは
|
|
135
|
+
# 表示側(search_init.js / search_page.js)で行う
|
|
136
|
+
def entry_snippet(entry)
|
|
137
|
+
desc = entry.description
|
|
138
|
+
desc unless desc.nil? || desc.empty?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def with_snippet(hash, entry)
|
|
142
|
+
snippet = entry_snippet(entry)
|
|
143
|
+
hash[:snippet] = snippet if snippet
|
|
144
|
+
hash
|
|
145
|
+
end
|
|
146
|
+
|
|
129
147
|
def class_entries(db)
|
|
130
148
|
db.classes.sort.reject(&:dummy?).map do |c|
|
|
131
|
-
{
|
|
149
|
+
with_snippet({
|
|
132
150
|
name: c.name,
|
|
133
151
|
full_name: c.name,
|
|
134
152
|
type: c.type.to_s,
|
|
135
153
|
path: "class/#{encode(c.name)}#{@suffix}",
|
|
136
|
-
}
|
|
154
|
+
}, c)
|
|
137
155
|
end
|
|
138
156
|
end
|
|
139
157
|
|
|
@@ -162,11 +180,11 @@ module BitClust
|
|
|
162
180
|
# sigil is the only thing distinguishing it, so keep it in +name+
|
|
163
181
|
# too (not just +full_name+) or a "$;"-style query can't match it.
|
|
164
182
|
name = full_name = tmark + mname
|
|
165
|
-
result << { name: name, full_name: full_name, type: type, path: path }
|
|
183
|
+
result << with_snippet({ name: name, full_name: full_name, type: type, path: path }, entry)
|
|
166
184
|
else
|
|
167
185
|
name = mname
|
|
168
186
|
full_name = cname + tmark + mname
|
|
169
|
-
item = { name: name, full_name: full_name, type: type, path: path } #: entry
|
|
187
|
+
item = with_snippet({ name: name, full_name: full_name, type: type, path: path }, entry) #: entry
|
|
170
188
|
if tmark.include?('.')
|
|
171
189
|
# bitclust#279: singleton methods (".") and module functions
|
|
172
190
|
# (".#"/"?.") keep a literal "." in full_name for display, but
|
|
@@ -185,12 +203,12 @@ module BitClust
|
|
|
185
203
|
|
|
186
204
|
def library_entries(db)
|
|
187
205
|
db.libraries.sort.map do |lib|
|
|
188
|
-
{
|
|
206
|
+
with_snippet({
|
|
189
207
|
name: lib.name,
|
|
190
208
|
full_name: lib.name,
|
|
191
209
|
type: 'library',
|
|
192
210
|
path: "library/#{encode(lib.name)}#{@suffix}",
|
|
193
|
-
}
|
|
211
|
+
}, lib)
|
|
194
212
|
end
|
|
195
213
|
end
|
|
196
214
|
|
|
@@ -201,12 +219,12 @@ module BitClust
|
|
|
201
219
|
# Prose pages are identified by a Japanese title, so index the title
|
|
202
220
|
# (for human queries) together with the slug (for identifier queries).
|
|
203
221
|
label = (title && !title.empty? && title != slug) ? "#{title} (#{slug})" : slug
|
|
204
|
-
{
|
|
222
|
+
with_snippet({
|
|
205
223
|
name: label,
|
|
206
224
|
full_name: label,
|
|
207
225
|
type: 'document',
|
|
208
226
|
path: "doc/#{encode(slug)}#{@suffix}",
|
|
209
|
-
}
|
|
227
|
+
}, doc)
|
|
210
228
|
end
|
|
211
229
|
end
|
|
212
230
|
|
|
@@ -272,12 +290,12 @@ module BitClust
|
|
|
272
290
|
|
|
273
291
|
def function_entries(fdb)
|
|
274
292
|
fdb.functions.sort.map do |func|
|
|
275
|
-
{
|
|
293
|
+
with_snippet({
|
|
276
294
|
name: func.name,
|
|
277
295
|
full_name: func.name,
|
|
278
296
|
type: 'function',
|
|
279
297
|
path: "function/#{func.name}#{@suffix}",
|
|
280
|
-
}
|
|
298
|
+
}, func)
|
|
281
299
|
end
|
|
282
300
|
end
|
|
283
301
|
|