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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +1 -0
  3. data/data/bitclust/catalog/ja_JP.UTF-8 +4 -2
  4. data/data/bitclust/template/class-index +0 -1
  5. data/data/bitclust/template/function-index +1 -0
  6. data/data/bitclust/template/layout +1 -0
  7. data/data/bitclust/template.epub/class +5 -5
  8. data/data/bitclust/template.epub/class-index +0 -1
  9. data/data/bitclust/template.epub/contents +7 -4
  10. data/data/bitclust/template.epub/layout +4 -4
  11. data/data/bitclust/template.offline/class-index +0 -1
  12. data/data/bitclust/template.offline/function-index +1 -0
  13. data/data/bitclust/template.offline/layout +3 -0
  14. data/lib/bitclust/classentry.rb +1 -1
  15. data/lib/bitclust/docentry.rb +1 -1
  16. data/lib/bitclust/functionentry.rb +1 -1
  17. data/lib/bitclust/generators/epub.rb +69 -5
  18. data/lib/bitclust/irb.rb +74 -0
  19. data/lib/bitclust/markdown_bridge.rb +11 -6
  20. data/lib/bitclust/markdown_exporter.rb +311 -0
  21. data/lib/bitclust/markdown_tree.rb +118 -11
  22. data/lib/bitclust/mdcompiler.rb +15 -1
  23. data/lib/bitclust/mdparser.rb +42 -1
  24. data/lib/bitclust/methoddatabase.rb +17 -4
  25. data/lib/bitclust/methodentry.rb +24 -0
  26. data/lib/bitclust/methodsignature.rb +1 -1
  27. data/lib/bitclust/nameutils.rb +13 -0
  28. data/lib/bitclust/preprocessor.rb +4 -37
  29. data/lib/bitclust/rbs_overload_matcher.rb +179 -0
  30. data/lib/bitclust/rbs_sig_importer.rb +276 -0
  31. data/lib/bitclust/rbs_signatures.rb +125 -0
  32. data/lib/bitclust/rdcompiler.rb +12 -1
  33. data/lib/bitclust/remote.rb +405 -0
  34. data/lib/bitclust/runner.rb +7 -10
  35. data/lib/bitclust/screen.rb +6 -1
  36. data/lib/bitclust/search_index_generator.rb +28 -10
  37. data/lib/bitclust/searcher.rb +54 -25
  38. data/lib/bitclust/subcommands/chm_command.rb +1 -1
  39. data/lib/bitclust/subcommands/epub_command.rb +2 -2
  40. data/lib/bitclust/subcommands/extract_command.rb +7 -0
  41. data/lib/bitclust/subcommands/htmlfile_command.rb +7 -0
  42. data/lib/bitclust/subcommands/methods_command.rb +5 -28
  43. data/lib/bitclust/subcommands/query_command.rb +1 -0
  44. data/lib/bitclust/subcommands/rbssig_command.rb +99 -0
  45. data/lib/bitclust/subcommands/server_command.rb +21 -10
  46. data/lib/bitclust/subcommands/setup_command.rb +32 -11
  47. data/lib/bitclust/subcommands/statichtml_command.rb +59 -0
  48. data/lib/bitclust/syntax_highlighter.rb +65 -4
  49. data/lib/bitclust/user_dirs.rb +62 -0
  50. data/lib/bitclust/version.rb +1 -1
  51. data/lib/bitclust-irb.rb +2 -0
  52. data/test/test_chm_command.rb +22 -0
  53. data/test/test_epub_command.rb +60 -0
  54. data/test/test_epub_generator.rb +330 -0
  55. data/test/test_extract_command.rb +45 -0
  56. data/test/test_irb_plugin.rb +212 -0
  57. data/test/test_link_checker.rb +1 -1
  58. data/test/test_markdown_bridge.rb +35 -0
  59. data/test/test_markdown_exporter.rb +260 -0
  60. data/test/test_markdown_tree.rb +144 -0
  61. data/test/test_mdcompiler.rb +47 -5
  62. data/test/test_mdparser.rb +223 -4
  63. data/test/test_methodentry.rb +111 -0
  64. data/test/test_methods_command.rb +32 -0
  65. data/test/test_methodsignature.rb +3 -1
  66. data/test/test_opensearchdescription_screen.rb +39 -0
  67. data/test/test_preprocessor.rb +26 -0
  68. data/test/test_query_command.rb +30 -0
  69. data/test/test_rbs_overload_matcher.rb +170 -0
  70. data/test/test_rbs_sig_importer.rb +237 -0
  71. data/test/test_rbssig_command.rb +186 -0
  72. data/test/test_rdcompiler.rb +99 -1
  73. data/test/test_remote.rb +374 -0
  74. data/test/test_runner.rb +61 -1
  75. data/test/test_search_index_generator.rb +81 -0
  76. data/test/test_searcher.rb +63 -0
  77. data/test/test_server_command.rb +41 -0
  78. data/test/test_setup_command.rb +164 -0
  79. data/test/test_statichtml_command.rb +137 -0
  80. data/test/test_syntax_highlighter.rb +124 -0
  81. data/test/test_user_dirs.rb +62 -0
  82. data/theme/default/js/run.js +14 -0
  83. data/theme/default/js/search_init.js +3 -1
  84. data/theme/default/js/search_page.js +7 -1
  85. data/theme/default/js/version_switcher.js +147 -0
  86. data/theme/default/search.css +21 -0
  87. data/theme/default/style.css +83 -7
  88. data/theme/lillia/style.css +26 -0
  89. metadata +71 -2
  90. data/lib/bitclust/ridatabase.rb +0 -359
@@ -28,9 +28,102 @@ class TestRDCompiler < Test::Unit::TestCase
28
28
  mock(method_entry).names.any_times{ names }
29
29
  mock(method_entry).since_map.any_times{ since_map }
30
30
  mock(method_entry).until_map.any_times{ until_map }
31
+ mock(method_entry).rbs_signature_overloads.any_times { nil }
31
32
  assert_equal(expected, @c.compile_method(method_entry))
32
33
  end
33
34
 
35
+ # RBS シグネチャ表示: rbs_sig property を持つエントリだけが、メソッド
36
+ # 見出しの <dt> 群の直後・説明 <dd> の直前に 1 オーバーロード 1 行の
37
+ # <dt class="rbs-signature"> を出す(dd だと説明の一部に見える。pre を
38
+ # 使うと script.js が COPY ボタンを付けてしまうので code のみ)。
39
+ # 型名セグメントは DB に存在するクラスだけリンク化し、テキストは
40
+ # エスケープした上で -> を &rarr; にする。
41
+ def test_method_with_rbs_signatures_renders_dt_lines
42
+ stub(@db).fetch_class('String') { :exists }
43
+ stub(@db).fetch_class('Integer') { raise BitClust::ClassNotFound, 'Integer' }
44
+ overloads = [
45
+ {'segments' => [['t', '('], ['c', 'Integer'], ['t', ') -> '], ['c', 'String']]},
46
+ {'segments' => [['t', '(String & Integer) -> void']]},
47
+ ]
48
+ method_entry = rbs_method_entry("--- index(val) -> Integer\n\n説明\n", overloads)
49
+
50
+ html = @c.compile_method(method_entry)
51
+ assert_include html,
52
+ %Q[(Integer) &rarr; <a href="dummy/class/String">String</a>]
53
+ assert_include html, '(String &amp; Integer) &rarr; void'
54
+ assert_match(
55
+ %r!</dt>\n(<dt class="rbs-signature"><code>.+</code></dt>\n){2}<dd class="method-description">!,
56
+ html)
57
+ assert_not_match(/<pre|<dd class="rbs-/, html)
58
+ end
59
+
60
+ # 引数パターンでチャンクが分かれるメソッド(Array.new 型)では、各
61
+ # オーバーロードが引数名・アリティ・ブロック有無で最も近いチャンクの
62
+ # 見出し直下にだけ出る(全チャンクへの重複表示をしない)
63
+ def test_method_with_rbs_signatures_assigns_overloads_to_chunks
64
+ src = <<~RD
65
+ --- index(sub) -> Integer | nil
66
+
67
+ 文字列で探す。
68
+
69
+ --- index(pattern) {|s| ... } -> Integer | nil
70
+
71
+ ブロックで探す。
72
+ RD
73
+ overloads = [
74
+ {'segments' => [['t', '(String sub) -> Integer?']],
75
+ 'params' => ['sub'], 'arity' => [1, 1]},
76
+ {'segments' => [['t', '(Regexp pattern) { (String) -> bool } -> Integer?']],
77
+ 'params' => ['pattern'], 'arity' => [1, 1], 'block' => 'req'},
78
+ ]
79
+ method_entry = rbs_method_entry(src, overloads)
80
+
81
+ html = @c.compile_method(method_entry)
82
+ assert_match(
83
+ %r!<code>index\(sub\).*?</dt>\n<dt class="rbs-signature"><code>\(String sub\) &rarr; Integer\?</code></dt>\n<dd!m,
84
+ html)
85
+ assert_match(
86
+ %r!<code>index\(pattern\).*?</dt>\n<dt class="rbs-signature"><code>\(Regexp pattern\) \{ \(String\) &rarr; bool \} &rarr; Integer\?</code></dt>\n<dd!m,
87
+ html)
88
+ assert_equal 2, html.scan('rbs-signature').size
89
+ end
90
+
91
+ # 旧形式の DB(メタ情報なし)ではチャンク振り分けができないので、
92
+ # 先頭チャンクにまとめて出す(重複はしない)
93
+ def test_method_with_rbs_signatures_without_meta_falls_back_to_first_chunk
94
+ src = <<~RD
95
+ --- index(sub) -> Integer | nil
96
+
97
+ 文字列で探す。
98
+
99
+ --- index(pattern) {|s| ... } -> Integer | nil
100
+
101
+ ブロックで探す。
102
+ RD
103
+ overloads = [
104
+ {'segments' => [['t', '(String sub) -> Integer?']]},
105
+ {'segments' => [['t', '(Regexp pattern) { (String) -> bool } -> Integer?']]},
106
+ ]
107
+ html = @c.compile_method(rbs_method_entry(src, overloads))
108
+ assert_equal 2, html.scan('rbs-signature').size
109
+ assert_match(
110
+ %r!<code>index\(sub\).*?</dt>\n(<dt class="rbs-signature">.*\n){2}<dd!,
111
+ html)
112
+ end
113
+
114
+ def rbs_method_entry(src, overloads)
115
+ method_entry = Object.new
116
+ mock(method_entry).source { src }
117
+ mock(method_entry).index_id.any_times { 'dummy' }
118
+ mock(method_entry).defined?.any_times { true }
119
+ mock(method_entry).id.any_times { 'String/i.index._builtin' }
120
+ mock(method_entry).names.any_times { [] }
121
+ mock(method_entry).since_map.any_times { {} }
122
+ mock(method_entry).until_map.any_times { {} }
123
+ mock(method_entry).rbs_signature_overloads.any_times { overloads }
124
+ method_entry
125
+ end
126
+
34
127
  # badge のカタログ訳(「Ruby %s から」等)を検証するテストで使う、
35
128
  # 日本語カタログを読み込んだ compiler を作る(setup の @c はカタログ無しの
36
129
  # C ロケールなので、_() はキーそのまま(英語)を返してしまう)
@@ -230,6 +323,7 @@ HERE
230
323
  mock(method_entry).names.any_times { ['mf'] }
231
324
  mock(method_entry).since_map.any_times { {} }
232
325
  mock(method_entry).until_map.any_times { {} }
326
+ mock(method_entry).rbs_signature_overloads.any_times { nil }
233
327
  mock(method_entry).klass.any_times { klass }
234
328
  mock(method_entry).display_typemark.any_times { '?.' }
235
329
 
@@ -250,6 +344,7 @@ HERE
250
344
  mock(method_entry).names.any_times { ['mf'] }
251
345
  mock(method_entry).since_map.any_times { {} }
252
346
  mock(method_entry).until_map.any_times { {} }
347
+ mock(method_entry).rbs_signature_overloads.any_times { nil }
253
348
 
254
349
  html = @c.compile_method(method_entry)
255
350
  assert_include(html, '<code>mf</code>')
@@ -979,7 +1074,7 @@ HERE
979
1074
 
980
1075
  data("library root" => ['[[lib:/]]', '<a href="dummy/library/">ライブラリ一覧</a>'],
981
1076
  "builtin library" => ['[[lib:_builtin]]', '<a href="dummy/library/_builtin">組み込みライブラリ</a>'],
982
- "C API root" => ['[[f:/]]', '<a href="dummy/function/">関数一覧</a>'])
1077
+ "C API root" => ['[[f:/]]', '<a href="dummy/function/">C API 関数一覧</a>'])
983
1078
  def test_bracket_link_with_catalog(data)
984
1079
  target, expected = data
985
1080
  prefix = File.expand_path('../data/bitclust/catalog', __dir__)
@@ -1029,6 +1124,7 @@ HERE
1029
1124
  mock(method_entry).names.any_times { [] }
1030
1125
  mock(method_entry).since_map.any_times { {} }
1031
1126
  mock(method_entry).until_map.any_times { {} }
1127
+ mock(method_entry).rbs_signature_overloads.any_times { nil }
1032
1128
  html = @c.compile_method(method_entry)
1033
1129
  assert_not_include(html, 'UNKNOWN_META_INFO')
1034
1130
  assert_not_include(html, '{:')
@@ -1052,6 +1148,7 @@ HERE
1052
1148
  mock(method_entry).names.any_times { [] }
1053
1149
  mock(method_entry).since_map.any_times { {} }
1054
1150
  mock(method_entry).until_map.any_times { {} }
1151
+ mock(method_entry).rbs_signature_overloads.any_times { nil }
1055
1152
  html = @c.compile_method(method_entry)
1056
1153
  assert_not_include(html, '{:')
1057
1154
  assert_include(html, 'to_a2')
@@ -1074,6 +1171,7 @@ HERE
1074
1171
  mock(method_entry).names.any_times { [] }
1075
1172
  mock(method_entry).since_map.any_times { {} }
1076
1173
  mock(method_entry).until_map.any_times { {} }
1174
+ mock(method_entry).rbs_signature_overloads.any_times { nil }
1077
1175
  html = @c.compile_method(method_entry)
1078
1176
  assert_not_include(html, '{:')
1079
1177
  assert_include(html, 'このメソッドは定義されていません。')
@@ -0,0 +1,374 @@
1
+ # frozen_string_literal: true
2
+ require 'test/unit'
3
+ require 'tmpdir'
4
+ require 'fileutils'
5
+ require 'stringio'
6
+ require 'json'
7
+ require 'time'
8
+ require 'bitclust'
9
+ require 'bitclust/searcher'
10
+ require 'bitclust/remote'
11
+
12
+ # ローカル DB が無いときのリモート検索(docs.ruby-lang.org の検索索引
13
+ # search_data.js と Markdown 配信をキャッシュ付きで使う)。
14
+ #
15
+ # テストリスト:
16
+ # Cache:
17
+ # [x] 未取得なら GET して保存し本文を返す
18
+ # [x] 1 日以内のキャッシュはネットワークに行かずに返す
19
+ # [x] 1 日以上古ければ If-Modified-Since 付きで確認し、304 なら touch して返す
20
+ # [x] 200 なら上書き保存する
21
+ # [x] 404 はキャッシュを消して nil
22
+ # [x] 接続エラー時にキャッシュがあれば古くても返して stale にする。無ければ例外
23
+ # [x] 本文は UTF-8 として扱う
24
+ # Index:
25
+ # [x] search_data.js を読んでクラス・メソッド(path から クラス・種別・名前)・ライブラリ・関数にする
26
+ # [x] メソッド指定は refe と同じ規則(クラスは大小無視の前方一致→完全一致、種別は typemark)
27
+ # [x] Kernel.#printf と Kernel?.printf は同じ module function。Kernel.printf は特異メソッドなので該当なし
28
+ # [x] 種別なし 2 語は全種別から探す
29
+ # [x] 3 語の種別は typemark として検査する(不正・$ は InvalidKey)
30
+ # [x] $ 始まりは特殊変数
31
+ # [x] A::B はクラス → 定数の順
32
+ # [x] 大文字 1 語はクラス → メソッド名の順、小文字 1 語はメソッド名 → クラス → ライブラリ → 関数の順
33
+ # [x] 語なしは全クラス、class_only: はクラスだけ
34
+ # [x] document・heading は検索対象にしない
35
+ # Remote#lookup:
36
+ # [x] 1 件に決まれば .md を取得して本文と出典 URL を書く
37
+ # [x] 複数なら名前一覧(line: なら 1 行 1 件、describe_all: なら全件の本文)
38
+ # [x] 見つからなければメッセージと検索ページ URL
39
+ # [x] 索引が取れずキャッシュも無ければメッセージだけ(例外にしない)
40
+ # [x] Remote.default は BITCLUST_REMOTE_URL が指定があればその URL
41
+ # [x] Remote.default は BITCLUST_REMOTE_URL が none(大小無視)か空なら nil(Windows では空の環境変数を作れない)
42
+ class TestRemote < Test::Unit::TestCase
43
+ BASE_URL = 'https://docs.ruby-lang.org/ja/latest/'
44
+
45
+ INDEX = {
46
+ index: [
47
+ { name: 'String', full_name: 'String', type: 'class', path: 'class/String.html' },
48
+ { name: 'StringIO', full_name: 'StringIO', type: 'class', path: 'class/StringIO.html' },
49
+ { name: 'Kernel', full_name: 'Kernel', type: 'module', path: 'class/Kernel.html' },
50
+ { name: 'File', full_name: 'File', type: 'class', path: 'class/File.html' },
51
+ { name: 'File::Stat', full_name: 'File::Stat', type: 'class', path: 'class/File=3a=3aStat.html' },
52
+ { name: 'ARGF', full_name: 'ARGF', type: 'object', path: 'class/ARGF.html' },
53
+ { name: 'gsub', full_name: 'String#gsub', type: 'instance_method', path: 'method/String/i/gsub.html' },
54
+ { name: 'gsub!', full_name: 'String#gsub!', type: 'instance_method', path: 'method/String/i/gsub=21.html' },
55
+ { name: 'size', full_name: 'String#size', type: 'instance_method', path: 'method/String/i/size.html' },
56
+ { name: 'size', full_name: 'StringIO#size', type: 'instance_method', path: 'method/StringIO/i/size.html' },
57
+ { name: 'size', full_name: 'File#size', type: 'instance_method', path: 'method/File/i/size.html' },
58
+ { name: 'size', full_name: 'File.size', type: 'class_method', path: 'method/File/s/size.html', match_name: 'File::size' },
59
+ { name: 'new', full_name: 'String.new', type: 'class_method', path: 'method/String/s/new.html', match_name: 'String::new' },
60
+ { name: 'printf', full_name: 'Kernel?.printf', type: 'class_method', path: 'method/Kernel/m/printf.html', match_name: 'Kernel::#printf' },
61
+ { name: 'SEPARATOR', full_name: 'File::SEPARATOR', type: 'constant', path: 'method/File/c/SEPARATOR.html' },
62
+ { name: '$stdout', full_name: '$stdout', type: 'variable', path: 'method/Kernel/v/stdout.html' },
63
+ { name: 'json', full_name: 'json', type: 'library', path: 'library/json.html' },
64
+ { name: 'net/http', full_name: 'net/http', type: 'library', path: 'library/net=2fhttp.html' },
65
+ { name: 'rb_str_new', full_name: 'rb_str_new', type: 'function', path: 'function/rb_str_new.html' },
66
+ { name: 'ReFe', full_name: 'ReFe', type: 'document', path: 'doc/ReFe.html' },
67
+ { name: 'Zzz', full_name: 'Zzz (Ruby用語集)', type: 'heading', path: 'doc/glossary.html#Zzz' },
68
+ ],
69
+ }.freeze
70
+ INDEX_JS = "var search_data = #{JSON.generate(INDEX)};\n"
71
+
72
+ PAGES = {
73
+ 'js/search_data.js' => INDEX_JS,
74
+ 'method/String/i/gsub.md' => "# String#gsub\n\n### def gsub(pattern, replace) -> String\n\n置き換えます。\n",
75
+ 'method/String/i/size.md' => "# String#size\n\n長さです。\n",
76
+ 'method/File/s/size.md' => "# File.size\n\nサイズです。\n",
77
+ 'method/File/i/size.md' => "# File#size\n\nサイズです。\n",
78
+ 'method/StringIO/i/size.md' => "# StringIO#size\n\nサイズです。\n",
79
+ 'class/String.md' => "# class String < Object\n\n文字列のクラスです。\n",
80
+ 'library/json.md' => "# library json\n\nJSON です。\n",
81
+ }.freeze
82
+
83
+ T0 = Time.utc(2026, 9, 2, 12, 0, 0)
84
+
85
+ def setup
86
+ @dir = Dir.mktmpdir
87
+ @saved_env = ENV['BITCLUST_REMOTE_URL']
88
+ ENV.delete('BITCLUST_REMOTE_URL')
89
+ end
90
+
91
+ def teardown
92
+ FileUtils.rm_rf(@dir)
93
+ @saved_env ? ENV['BITCLUST_REMOTE_URL'] = @saved_env : ENV.delete('BITCLUST_REMOTE_URL')
94
+ end
95
+
96
+ # pages: base_url からの相対パス => 本文。無いパスは 404。requests に
97
+ # [URL, ヘッダ] を記録する
98
+ def fake_fetcher(pages = PAGES, requests = [])
99
+ lambda do |uri, headers|
100
+ requests << [uri.to_s, headers]
101
+ rel = uri.to_s.delete_prefix(BASE_URL)
102
+ pages.key?(rel) ? [200, pages[rel].dup] : [404, '<Error><Code>AccessDenied</Code></Error>']
103
+ end
104
+ end
105
+
106
+ def remote(pages = PAGES, requests = [], now: T0)
107
+ BitClust::Remote.new(base_url: BASE_URL, fetcher: fake_fetcher(pages, requests),
108
+ cache_dir: @dir, clock: -> { now })
109
+ end
110
+
111
+ def index
112
+ BitClust::Remote::Index.parse(INDEX_JS)
113
+ end
114
+
115
+ def names(entries)
116
+ entries.map(&:full_name)
117
+ end
118
+
119
+ def cache(fetcher, now: T0)
120
+ BitClust::Remote::Cache.new(dir: @dir, fetcher: fetcher, clock: -> { now })
121
+ end
122
+
123
+ def gsub_uri
124
+ URI("#{BASE_URL}method/String/i/gsub.md")
125
+ end
126
+
127
+ # --- Cache ---
128
+
129
+ def test_cache_fetches_and_stores
130
+ requests = []
131
+ hit = cache(fake_fetcher(PAGES, requests)).fetch(gsub_uri)
132
+ assert_match(/置き換えます。/, hit.body)
133
+ assert_false(hit.stale)
134
+ assert_equal([[gsub_uri.to_s, {}]], requests)
135
+ path = File.join(@dir, 'docs.ruby-lang.org', 'ja', 'latest', 'method', 'String', 'i', 'gsub.md')
136
+ assert_equal(PAGES['method/String/i/gsub.md'], File.read(path, encoding: 'UTF-8'))
137
+ assert_equal(T0, File.mtime(path))
138
+ end
139
+
140
+ def test_cache_reuses_fresh_copy_without_network
141
+ requests = []
142
+ c = cache(fake_fetcher(PAGES, requests))
143
+ c.fetch(gsub_uri)
144
+ later = cache(fake_fetcher(PAGES, requests), now: T0 + 23 * 3600)
145
+ hit = later.fetch(gsub_uri)
146
+ assert_match(/置き換えます。/, hit.body)
147
+ assert_equal(1, requests.size)
148
+ end
149
+
150
+ def test_cache_revalidates_old_copy_with_if_modified_since
151
+ requests = []
152
+ cache(fake_fetcher(PAGES, requests)).fetch(gsub_uri)
153
+ not_modified = lambda do |uri, headers|
154
+ requests << [uri.to_s, headers]
155
+ [304, '']
156
+ end
157
+ now = T0 + 25 * 3600
158
+ hit = cache(not_modified, now: now).fetch(gsub_uri)
159
+ assert_match(/置き換えます。/, hit.body)
160
+ assert_false(hit.stale)
161
+ assert_equal(2, requests.size)
162
+ assert_equal({ 'If-Modified-Since' => T0.httpdate }, requests.last[1])
163
+ assert_equal(now, File.mtime(hit.path))
164
+ end
165
+
166
+ def test_cache_overwrites_on_200
167
+ cache(fake_fetcher(PAGES)).fetch(gsub_uri)
168
+ updated = PAGES.merge('method/String/i/gsub.md' => "# String#gsub\n\n新しい説明。\n")
169
+ hit = cache(fake_fetcher(updated), now: T0 + 2 * 86400).fetch(gsub_uri)
170
+ assert_match(/新しい説明。/, hit.body)
171
+ assert_equal("# String#gsub\n\n新しい説明。\n", File.read(hit.path, encoding: 'UTF-8'))
172
+ end
173
+
174
+ def test_cache_removes_copy_on_404
175
+ c = cache(fake_fetcher(PAGES))
176
+ hit = c.fetch(gsub_uri)
177
+ assert_nil(cache(fake_fetcher({}), now: T0 + 2 * 86400).fetch(gsub_uri))
178
+ assert_false(File.exist?(hit.path))
179
+ assert_nil(cache(fake_fetcher({})).fetch(URI("#{BASE_URL}class/Nope.md")))
180
+ end
181
+
182
+ def test_cache_serves_stale_copy_when_offline
183
+ failing = ->(_uri, _headers) { raise SocketError, 'getaddrinfo failed' }
184
+ assert_raise(SocketError) { cache(failing).fetch(gsub_uri) }
185
+ cache(fake_fetcher(PAGES)).fetch(gsub_uri)
186
+ hit = cache(failing, now: T0 + 2 * 86400).fetch(gsub_uri)
187
+ assert_match(/置き換えます。/, hit.body)
188
+ assert_true(hit.stale)
189
+ end
190
+
191
+ def test_cache_body_is_utf8
192
+ binary = { 'class/String.md' => "# class String < Object\n\n文字列のクラスです。\n".b }
193
+ hit = cache(fake_fetcher(binary)).fetch(URI("#{BASE_URL}class/String.md"))
194
+ assert_equal(Encoding::UTF_8, hit.body.encoding)
195
+ assert_true(hit.body.valid_encoding?)
196
+ again = cache(fake_fetcher({})).fetch(URI("#{BASE_URL}class/String.md"))
197
+ assert_equal(Encoding::UTF_8, again.body.encoding)
198
+ assert_match(/文字列のクラスです。/, again.body)
199
+ end
200
+
201
+ # --- Index ---
202
+
203
+ def test_index_parses_entries
204
+ i = index
205
+ e = i.search(%w[String#gsub]).first
206
+ assert_equal(['String', 'i', 'gsub', 'method/String/i/gsub.html'], [e.klass, e.typechar, e.name, e.path])
207
+ assert_equal(%w[File.size], names(i.search(%w[File.size])))
208
+ assert_equal(%w[net/http], names(i.search(%w[net/http])))
209
+ end
210
+
211
+ def test_index_method_spec_follows_refe_rules
212
+ i = index
213
+ assert_equal(%w[String#gsub], names(i.search(%w[String#gsub])))
214
+ assert_equal(%w[String#gsub], names(i.search(%w[str#gsub])))
215
+ assert_equal(%w[String#gsub String#gsub!], names(i.search(%w[Str#gs])))
216
+ assert_equal(%w[String#gsub!], names(i.search(%w[String#gsub!])))
217
+ assert_equal(%w[String.new], names(i.search(%w[String.new])))
218
+ assert_equal(%w[String#gsub], names(i.search(%w[String,gsub])))
219
+ assert_equal([], names(i.search(%w[String#nope])))
220
+ assert_equal([], names(i.search(%w[Nope#gsub])))
221
+ end
222
+
223
+ def test_index_module_function_typemarks
224
+ i = index
225
+ assert_equal(%w[Kernel?.printf], names(i.search(%w[Kernel.#printf])))
226
+ assert_equal(%w[Kernel?.printf], names(i.search(%w[Kernel?.printf])))
227
+ assert_equal([], names(i.search(%w[Kernel.printf])))
228
+ end
229
+
230
+ def test_index_two_words_search_all_types
231
+ i = index
232
+ assert_equal(%w[File#size File.size], names(i.search(%w[File size])))
233
+ assert_equal(%w[String#size StringIO#size], names(i.search(%w[str size])))
234
+ assert_equal([], names(i.search(%w[String nope])))
235
+ end
236
+
237
+ def test_index_three_words_check_typemark
238
+ i = index
239
+ assert_equal(%w[File.size], names(i.search(%w[File . size])))
240
+ assert_equal(%w[File#size], names(i.search(%w[File # size])))
241
+ assert_raise(BitClust::InvalidKey) { i.search(%w[File x size]) }
242
+ assert_raise(BitClust::InvalidKey) { i.search(%w[Kernel $ stdout]) }
243
+ assert_raise(BitClust::InvalidKey) { i.search(%w[a b c d]) }
244
+ end
245
+
246
+ def test_index_special_variable
247
+ i = index
248
+ assert_equal(%w[$stdout], names(i.search(%w[$stdout])))
249
+ assert_equal(%w[$stdout], names(i.search(%w[$std])))
250
+ assert_equal([], names(i.search(%w[$nope])))
251
+ end
252
+
253
+ def test_index_scoped_name_prefers_class_then_constant
254
+ i = index
255
+ assert_equal(%w[File::Stat], names(i.search(%w[File::Stat])))
256
+ assert_equal(%w[File::SEPARATOR], names(i.search(%w[File::SEPARATOR])))
257
+ assert_equal([], names(i.search(%w[File::NOPE])))
258
+ end
259
+
260
+ def test_index_single_word_order
261
+ i = index
262
+ assert_equal(%w[String], names(i.search(%w[String])))
263
+ assert_equal(%w[String StringIO], names(i.search(%w[S])))
264
+ assert_equal(%w[File#size File.size String#size StringIO#size], names(i.search(%w[size])).sort)
265
+ assert_equal(%w[Kernel?.printf], names(i.search(%w[printf])))
266
+ assert_equal(%w[json], names(i.search(%w[json])))
267
+ assert_equal(%w[rb_str_new], names(i.search(%w[rb_str_new])))
268
+ assert_equal(%w[ARGF], names(i.search(%w[argf])))
269
+ assert_equal([], names(i.search(%w[nothing])))
270
+ end
271
+
272
+ def test_index_no_words_and_class_only
273
+ i = index
274
+ assert_equal(%w[ARGF File File::Stat Kernel String StringIO], names(i.search([])).sort)
275
+ assert_equal(%w[String StringIO], names(i.search(%w[S], class_only: true)))
276
+ assert_equal([], names(i.search(%w[size], class_only: true)))
277
+ assert_raise(BitClust::InvalidKey) { i.search(%w[a b], class_only: true) }
278
+ end
279
+
280
+ def test_index_ignores_documents_and_headings
281
+ i = index
282
+ assert_equal([], names(i.search(%w[ReFe])))
283
+ assert_equal([], names(i.search(%w[Zzz])))
284
+ end
285
+
286
+ # --- Remote#lookup ---
287
+
288
+ def test_lookup_single_hit_shows_page_with_source_url
289
+ requests = []
290
+ out = StringIO.new
291
+ assert_true(remote(PAGES, requests).lookup(%w[String#gsub], out))
292
+ assert_match(/\A# String#gsub$/, out.string)
293
+ assert_match(/置き換えます。/, out.string)
294
+ assert_match(%r{^https://docs.ruby-lang.org/ja/latest/method/String/i/gsub.html$}, out.string)
295
+ assert_equal(["#{BASE_URL}js/search_data.js", "#{BASE_URL}method/String/i/gsub.md"], requests.map(&:first))
296
+ end
297
+
298
+ def test_lookup_multiple_hits_lists_names
299
+ out = StringIO.new
300
+ assert_true(remote.lookup(%w[size], out))
301
+ assert_equal("File#size File.size String#size StringIO#size \n", out.string)
302
+ out = StringIO.new
303
+ remote.lookup(%w[size], out, line: true)
304
+ assert_equal("File#size\nFile.size\nString#size\nStringIO#size\n", out.string)
305
+ out = StringIO.new
306
+ remote.lookup(%w[size], out, describe_all: true)
307
+ assert_match(/# File#size.*# File\.size.*# String#size.*# StringIO#size/m, out.string)
308
+ end
309
+
310
+ def test_lookup_single_hit_with_line_prints_name_only
311
+ out = StringIO.new
312
+ remote.lookup(%w[String#gsub], out, line: true)
313
+ assert_equal("String#gsub\n", out.string)
314
+ end
315
+
316
+ def test_lookup_class_only
317
+ out = StringIO.new
318
+ remote.lookup(%w[String], out, class_only: true)
319
+ assert_match(/文字列のクラスです。/, out.string)
320
+ end
321
+
322
+ def test_lookup_not_found_shows_search_url
323
+ out = StringIO.new
324
+ assert_nothing_raised do
325
+ assert_false(remote.lookup(%w[NoSuchClass#nope], out))
326
+ end
327
+ assert_match(/no such method: NoSuchClass#nope/, out.string)
328
+ assert_match(%r{https://docs.ruby-lang.org/ja/search/\?q=NoSuchClass%23nope}, out.string)
329
+ out = StringIO.new
330
+ assert_false(remote.lookup(%w[Nope], out, class_only: true))
331
+ assert_match(/no such class: Nope/, out.string)
332
+ end
333
+
334
+ def test_lookup_without_index_reports_instead_of_raising
335
+ failing = BitClust::Remote.new(base_url: BASE_URL, cache_dir: @dir, clock: -> { T0 },
336
+ fetcher: ->(_uri, _headers) { raise SocketError, 'getaddrinfo failed' })
337
+ out = StringIO.new
338
+ assert_nothing_raised do
339
+ assert_false(failing.lookup(%w[String#gsub], out))
340
+ end
341
+ assert_match(/docs\.ruby-lang\.org/, out.string)
342
+ assert_match(/getaddrinfo/, out.string)
343
+ assert_match(/bitclust setup/, out.string)
344
+ end
345
+
346
+ def test_lookup_uses_cached_index_when_offline
347
+ remote.lookup(%w[String#gsub], StringIO.new)
348
+ failing = BitClust::Remote.new(base_url: BASE_URL, cache_dir: @dir, clock: -> { T0 + 3 * 86400 },
349
+ fetcher: ->(_uri, _headers) { raise SocketError, 'getaddrinfo failed' })
350
+ out = StringIO.new
351
+ assert_true(failing.lookup(%w[String#gsub], out))
352
+ assert_match(/置き換えます。/, out.string)
353
+ assert_match(/オフライン/, out.string)
354
+ end
355
+
356
+ def test_default_follows_environment
357
+ assert_equal(BitClust::Remote::DEFAULT_BASE_URL, BitClust::Remote.default.base_url)
358
+ ENV['BITCLUST_REMOTE_URL'] = 'http://localhost:10080/ja/latest'
359
+ assert_equal('http://localhost:10080/ja/latest/', BitClust::Remote.default.base_url)
360
+ end
361
+
362
+ # Windows のシェル(cmd.exe・PowerShell)では空の環境変数を作れないので、
363
+ # 無効化は "none" でもできる
364
+ def test_default_is_disabled_by_none_or_empty
365
+ %w[none NONE None].each do |value|
366
+ ENV['BITCLUST_REMOTE_URL'] = value
367
+ assert_nil(BitClust::Remote.default, value)
368
+ end
369
+ ENV['BITCLUST_REMOTE_URL'] = ''
370
+ assert_nil(BitClust::Remote.default)
371
+ ENV['BITCLUST_REMOTE_URL'] = 'http://none.example/'
372
+ assert_equal('http://none.example/', BitClust::Remote.default.base_url)
373
+ end
374
+ end
data/test/test_runner.rb CHANGED
@@ -1,5 +1,8 @@
1
1
  require 'bitclust'
2
2
  require 'bitclust/runner'
3
+ require 'bitclust/user_dirs'
4
+ require 'tmpdir'
5
+ require 'fileutils'
3
6
 
4
7
  class TestRunner < Test::Unit::TestCase
5
8
  def setup
@@ -116,7 +119,10 @@ class TestRunner < Test::Unit::TestCase
116
119
  original_stderr = $stderr
117
120
  $stderr = StringIO.new
118
121
  assert_raise(SystemExit) { @runner.run(["init"]) }
119
- assert_include($stderr.string, "--database")
122
+ message = $stderr.string
123
+ assert_include(message, "--database (-d)")
124
+ assert_include(message, "bitclust setup")
125
+ assert_include(message, BitClust::UserDirs.config_candidates.join(' or '))
120
126
  ensure
121
127
  $stderr = original_stderr
122
128
  end
@@ -146,3 +152,57 @@ class TestRunner < Test::Unit::TestCase
146
152
  end
147
153
  end
148
154
  end
155
+
156
+ # Runner#load_config が UserDirs 経由で設定ファイルを読むこと(実ファイルで検証)。
157
+ #
158
+ # テストリスト:
159
+ # [x] XDG の場所(~/.config/bitclust/config)にあればそれを読む
160
+ # [x] 従来の場所(~/.bitclust/config)にあればそれを読む
161
+ # [x] どちらも無ければ nil
162
+ class TestRunnerLoadConfig < Test::Unit::TestCase
163
+ ENV_KEYS = %w[HOME XDG_CONFIG_HOME XDG_DATA_HOME XDG_CACHE_HOME
164
+ REFE2_SERVER BITCLUST_SERVER REFE2_DATADIR BITCLUST_DATADIR].freeze
165
+
166
+ def setup
167
+ @saved = ENV_KEYS.to_h {|k| [k, ENV[k]] }
168
+ @home = Dir.mktmpdir
169
+ ENV['HOME'] = @home
170
+ (ENV_KEYS - %w[HOME]).each {|k| ENV.delete(k) }
171
+ @runner = BitClust::Runner.new
172
+ end
173
+
174
+ def teardown
175
+ @saved.each {|k, v| v ? ENV[k] = v : ENV.delete(k) }
176
+ FileUtils.rm_rf(@home)
177
+ end
178
+
179
+ def write_config(path, prefix, version)
180
+ FileUtils.mkdir_p(File.dirname(path))
181
+ File.write(path, { :default_version => version, :database_prefix => prefix }.to_yaml)
182
+ end
183
+
184
+ def test_load_config_from_xdg_location
185
+ write_config(File.join(@home, '.config', 'bitclust', 'config'), '/xdg/db', '4.0')
186
+ config = @runner.load_config
187
+ assert_equal('/xdg/db', config[:database_prefix])
188
+ assert_equal('4.0', config[:default_version])
189
+ end
190
+
191
+ def test_load_config_from_legacy_location
192
+ write_config(File.join(@home, '.bitclust', 'config'), '/legacy/db', '3.4')
193
+ config = @runner.load_config
194
+ assert_equal('/legacy/db', config[:database_prefix])
195
+ assert_equal('3.4', config[:default_version])
196
+ end
197
+
198
+ def test_load_config_prefers_xdg_over_legacy
199
+ write_config(File.join(@home, '.bitclust', 'config'), '/legacy/db', '3.4')
200
+ write_config(File.join(@home, '.config', 'bitclust', 'config'), '/xdg/db', '4.0')
201
+ config = @runner.load_config
202
+ assert_equal('/xdg/db', config[:database_prefix])
203
+ end
204
+
205
+ def test_load_config_nil_without_any_config_file
206
+ assert_nil(@runner.load_config)
207
+ end
208
+ end