bitclust-core 1.7.0 → 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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/data/bitclust/template/class-index +0 -1
  3. data/data/bitclust/template.epub/class +5 -5
  4. data/data/bitclust/template.epub/class-index +0 -1
  5. data/data/bitclust/template.epub/contents +7 -4
  6. data/data/bitclust/template.epub/layout +4 -4
  7. data/data/bitclust/template.offline/class-index +0 -1
  8. data/lib/bitclust/generators/epub.rb +69 -5
  9. data/lib/bitclust/irb.rb +14 -8
  10. data/lib/bitclust/markdown_bridge.rb +11 -6
  11. data/lib/bitclust/markdown_tree.rb +118 -11
  12. data/lib/bitclust/methoddatabase.rb +17 -4
  13. data/lib/bitclust/nameutils.rb +13 -0
  14. data/lib/bitclust/preprocessor.rb +0 -36
  15. data/lib/bitclust/remote.rb +405 -0
  16. data/lib/bitclust/runner.rb +5 -10
  17. data/lib/bitclust/screen.rb +2 -1
  18. data/lib/bitclust/searcher.rb +43 -24
  19. data/lib/bitclust/subcommands/chm_command.rb +1 -1
  20. data/lib/bitclust/subcommands/epub_command.rb +2 -2
  21. data/lib/bitclust/subcommands/extract_command.rb +7 -0
  22. data/lib/bitclust/subcommands/htmlfile_command.rb +7 -0
  23. data/lib/bitclust/subcommands/methods_command.rb +5 -28
  24. data/lib/bitclust/subcommands/query_command.rb +1 -0
  25. data/lib/bitclust/subcommands/server_command.rb +21 -10
  26. data/lib/bitclust/subcommands/setup_command.rb +28 -10
  27. data/lib/bitclust/syntax_highlighter.rb +65 -4
  28. data/lib/bitclust/user_dirs.rb +62 -0
  29. data/lib/bitclust/version.rb +1 -1
  30. data/test/test_chm_command.rb +22 -0
  31. data/test/test_epub_command.rb +60 -0
  32. data/test/test_epub_generator.rb +330 -0
  33. data/test/test_extract_command.rb +45 -0
  34. data/test/test_irb_plugin.rb +100 -6
  35. data/test/test_markdown_bridge.rb +35 -0
  36. data/test/test_markdown_tree.rb +144 -0
  37. data/test/test_mdparser.rb +39 -0
  38. data/test/test_methods_command.rb +32 -0
  39. data/test/test_opensearchdescription_screen.rb +39 -0
  40. data/test/test_query_command.rb +30 -0
  41. data/test/test_remote.rb +374 -0
  42. data/test/test_runner.rb +61 -1
  43. data/test/test_searcher.rb +63 -0
  44. data/test/test_server_command.rb +41 -0
  45. data/test/test_setup_command.rb +143 -0
  46. data/test/test_syntax_highlighter.rb +124 -0
  47. data/test/test_user_dirs.rb +62 -0
  48. metadata +37 -2
  49. data/lib/bitclust/ridatabase.rb +0 -359
@@ -366,4 +366,148 @@ class TestMarkdownTree < Test::Unit::TestCase
366
366
  "version-range-gated H1 relations must not be flagged"
367
367
  assert_equal [], tree.warnings
368
368
  end
369
+
370
+ # --- front matter ゲート付きリストのディレクティブ(bitclust#331) ---
371
+ # テストリスト:
372
+ # [x] #%else は直前ゲートの反転(until → since / since → until)
373
+ # [x] #%version A...B / A... / ...B / V(単一版)
374
+ # [x] #%version A...B の #%else は「A 未満」と「B 以上」の 2 membership
375
+ # [x] #%version V の #%else は except: [V]
376
+ # [x] 入れ子のゲートは交差(since は max・until は min)
377
+ # [x] 矛盾する入れ子(単一版 ≠ 単一版)は membership を作らない
378
+ # [x] #%# コメント行は無視
379
+ # [x] 未知の #% 行・#%if・対応のない #%else/#%end・二重 #%else・
380
+ # リスト終端で閉じていないゲートは ParseError(無音の打ち切りをやめる)
381
+
382
+ def membership_scan(list)
383
+ scan(
384
+ "a.md" => LIB, "b.md" => LIB, "c.md" => LIB,
385
+ "a/X.md" => "---\nlibrary:\n#{list}---\n# class X < Object\n"
386
+ )
387
+ end
388
+
389
+ def memberships_of(list)
390
+ tree = membership_scan(list)
391
+ assert_equal [], tree.warnings
392
+ tree.entities["a/X.md"][:memberships]
393
+ end
394
+
395
+ def test_else_inverts_until
396
+ assert_equal [
397
+ { library: "a", until: "3.4" },
398
+ { library: "b", since: "3.4" },
399
+ ], memberships_of("#%until 3.4\n - a\n#%else\n - b\n#%end\n")
400
+ end
401
+
402
+ def test_else_inverts_since
403
+ assert_equal [
404
+ { library: "a", since: "3.4" },
405
+ { library: "b", until: "3.4" },
406
+ ], memberships_of("#%since 3.4\n - a\n#%else\n - b\n#%end\n")
407
+ end
408
+
409
+ def test_version_range_forms
410
+ assert_equal [
411
+ { library: "a", since: "3.1", until: "3.3" },
412
+ { library: "b", since: "3.1" },
413
+ { library: "c", until: "3.3" },
414
+ ], memberships_of(
415
+ "#%version 3.1...3.3\n - a\n#%end\n" \
416
+ "#%version 3.1...\n - b\n#%end\n" \
417
+ "#%version ...3.3\n - c\n#%end\n"
418
+ )
419
+ end
420
+
421
+ def test_single_version
422
+ assert_equal [{ library: "a", version: "3.1" }],
423
+ memberships_of("#%version 3.1\n - a\n#%end\n")
424
+ end
425
+
426
+ def test_else_of_version_range_splits_into_two_memberships
427
+ assert_equal [
428
+ { library: "a", since: "3.1", until: "3.3" },
429
+ { library: "b", until: "3.1" },
430
+ { library: "b", since: "3.3" },
431
+ ], memberships_of("#%version 3.1...3.3\n - a\n#%else\n - b\n#%end\n")
432
+ end
433
+
434
+ def test_else_of_single_version_is_except
435
+ assert_equal [
436
+ { library: "a", version: "3.1" },
437
+ { library: "b", except: ["3.1"] },
438
+ ], memberships_of("#%version 3.1\n - a\n#%else\n - b\n#%end\n")
439
+ end
440
+
441
+ def test_nested_gates_intersect
442
+ assert_equal [
443
+ { library: "a", since: "3.2", until: "3.4" },
444
+ { library: "b", since: "3.0", until: "3.2" },
445
+ ], memberships_of(
446
+ "#%since 3.0\n#%until 3.4\n#%since 3.2\n - a\n#%else\n - b\n#%end\n#%end\n#%end\n"
447
+ )
448
+ end
449
+
450
+ def test_contradicting_nested_single_versions_yield_no_membership
451
+ assert_equal [{ library: "b", version: "3.1" }],
452
+ memberships_of("#%version 3.1\n#%version 3.2\n - a\n#%end\n - b\n#%end\n")
453
+ end
454
+
455
+ def test_directive_comment_lines_are_ignored
456
+ assert_equal [{ library: "a" }, { library: "b", until: "3.4" }],
457
+ memberships_of("#%# comment\n - a\n#%until 3.4\n#%# another\n - b\n#%end\n")
458
+ end
459
+
460
+ def test_at_prefix_else_and_version
461
+ assert_equal [
462
+ { library: "a", since: "3.1", until: "3.3" },
463
+ { library: "b", until: "3.1" },
464
+ { library: "b", since: "3.3" },
465
+ ], memberships_of("\#@version 3.1...3.3\n - a\n\#@else\n - b\n\#@end\n")
466
+ end
467
+
468
+ def assert_list_parse_error(list, pattern)
469
+ e = assert_raise(BitClust::ParseError) { membership_scan(list) }
470
+ assert_match pattern, e.message
471
+ assert_match(/a\/X\.md:\d+/, e.message, "message must locate the file and line")
472
+ end
473
+
474
+ def test_unknown_directive_in_list_raises
475
+ assert_list_parse_error(" - a\n#%sinse 3.4\n - b\n#%end\n", /unknown preprocessor directive/)
476
+ end
477
+
478
+ def test_if_in_list_is_unsupported
479
+ assert_list_parse_error("#%if (version >= \"3.4\")\n - a\n#%end\n", /#%if is not supported/)
480
+ end
481
+
482
+ def test_else_without_gate_raises
483
+ assert_list_parse_error(" - a\n#%else\n - b\n#%end\n", /no matching/)
484
+ end
485
+
486
+ def test_end_without_gate_raises
487
+ assert_list_parse_error(" - a\n#%end\n", /no matching/)
488
+ end
489
+
490
+ def test_duplicate_else_raises
491
+ assert_list_parse_error("#%since 3.4\n - a\n#%else\n - b\n#%else\n - c\n#%end\n", /duplicate/)
492
+ end
493
+
494
+ def test_unterminated_gate_at_list_end_raises
495
+ assert_list_parse_error("#%since 3.4\n - a\n", /unterminated/)
496
+ end
497
+
498
+ def test_indented_directive_in_list_raises
499
+ # 列 0 でない #% 行は Preprocessor でも指令にならない(YAML コメント扱い)。
500
+ # リスト内で無音にゲートが無視されたりリストが打ち切られたりしないよう、
501
+ # 指令風のインデント行はエラーにする
502
+ assert_list_parse_error(" - a\n #%since 3.0\n - b\n#%end\n", /must start at column 0/)
503
+ end
504
+
505
+ def test_yaml_comment_in_list_is_ignored
506
+ assert_equal [{ library: "a" }, { library: "b", since: "3.0" }],
507
+ memberships_of(" # plain yaml comment\n - a\n#%since 3.0\n # another\n - b\n#%end\n")
508
+ end
509
+
510
+ def test_wrong_version_range_raises
511
+ assert_list_parse_error("#%version 3.1..3.3\n - a\n#%end\n", /wrong version range/)
512
+ end
369
513
  end
@@ -412,6 +412,45 @@ class TestMDParser < Test::Unit::TestCase
412
412
  end
413
413
  end
414
414
 
415
+ def test_update_by_markdowntree_else_and_single_version_memberships
416
+ # bitclust#331: ゲート付き library リストの #@version V(単一版)と #@else。
417
+ # 3.2 だけ onlylib、それ以外は builtin に属する。版の比較は Gem::Version
418
+ # なので 3.2.0 の DB でも 3.2 と同じ版として扱う
419
+ require 'tmpdir'
420
+ Dir.mktmpdir do |root|
421
+ write = ->(rel, s) {
422
+ path = File.join(root, rel)
423
+ FileUtils.mkdir_p(File.dirname(path))
424
+ File.write(path, s)
425
+ }
426
+ write.call('builtin.md', "---\ntype: library\n---\nbuiltin。\n")
427
+ write.call('onlylib.md', "---\ntype: library\n---\nonlylib。\n")
428
+ write.call('onlylib/Shared.md', <<~'MD')
429
+ ---
430
+ library:
431
+ #@version 3.2
432
+ - onlylib
433
+ #@else
434
+ - builtin
435
+ #@end
436
+ ---
437
+ # class Shared < Object
438
+
439
+ 共有クラス。
440
+ MD
441
+
442
+ classes = ->(version) {
443
+ db = BitClust::MethodDatabase.dummy("version" => version)
444
+ db.update_by_markdowntree(root)
445
+ %w[builtin onlylib].map { |lib| db.fetch_library(lib).classes.map(&:name) }
446
+ }
447
+ assert_equal [[], %w[Shared]], classes.call("3.2")
448
+ assert_equal [[], %w[Shared]], classes.call("3.2.0")
449
+ assert_equal [%w[Shared], []], classes.call("3.4")
450
+ assert_equal [%w[Shared], []], classes.call("3.0")
451
+ end
452
+ end
453
+
415
454
  def test_update_by_markdowntree_parse_error_suggests_gem_update
416
455
  # manual/ が新しい bitclust を必要とする記法を含むとき、古い gem では
417
456
  # パースエラーになる。原因へたどり着けるよう gem 更新の案内を添える
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+ require 'test/unit'
3
+ require 'bitclust'
4
+ require 'bitclust/subcommands/methods_command'
5
+
6
+ class TestMethodsCommand < Test::Unit::TestCase
7
+ def setup
8
+ @cmd = BitClust::Subcommands::MethodsCommand.new
9
+ end
10
+
11
+ def test_needs_no_database
12
+ assert_false(@cmd.needs_database?)
13
+ end
14
+
15
+ def test_content_option_is_not_accepted
16
+ assert_raise(OptionParser::InvalidOption) do
17
+ @cmd.parse(['-c', 'Object'])
18
+ end
19
+ end
20
+
21
+ def test_ri_database_option_is_not_accepted
22
+ assert_raise(OptionParser::InvalidOption) do
23
+ @cmd.parse(['--ri-database=/tmp/ri', 'Object'])
24
+ end
25
+ end
26
+
27
+ def test_diff_and_ruby_options_are_kept
28
+ assert_nothing_raised do
29
+ @cmd.parse(['--ruby=3.4.8', '--diff=refm/api/src/_builtin/Object', 'Object'])
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,39 @@
1
+ require 'test/unit'
2
+ require 'uri'
3
+ require 'bitclust'
4
+ require 'bitclust/screen'
5
+
6
+ class TestOpenSearchDescriptionScreen < Test::Unit::TestCase
7
+ SRC = <<'HERE'
8
+ = class Foo
9
+ description
10
+ HERE
11
+
12
+ def screen
13
+ _lib, db = BitClust::RRDParser.parse(SRC, 'testlib', {'version' => '3.4'})
14
+ datadir = File.expand_path('../data/bitclust', __dir__)
15
+ manager = BitClust::ScreenManager.new(
16
+ :templatedir => "#{datadir}/template",
17
+ :catalogdir => "#{datadir}/catalog",
18
+ :encoding => 'utf-8',
19
+ :default_encoding => 'utf-8',
20
+ :base_url => '',
21
+ :cgi_url => '/view',
22
+ :target_version => '3.4'
23
+ )
24
+ manager.opensearchdescription_screen(URI.parse('http://example.com/view/opensearchdescription'),
25
+ :database => db)
26
+ end
27
+
28
+ def test_body_is_bare_xml_without_html_layout
29
+ body = screen.body
30
+ assert_match(/\A<\?xml/, body)
31
+ assert_not_include(body, '<html')
32
+ assert_match(%r{\A<\?xml[^>]*\?>\s*<OpenSearchDescription\b.*</OpenSearchDescription>\s*\z}m, body)
33
+ assert_include(body, 'http://example.com/view/search')
34
+ end
35
+
36
+ def test_content_type_is_opensearch_xml
37
+ assert_equal 'application/opensearchdescription+xml; charset=utf-8', screen.content_type
38
+ end
39
+ end
@@ -0,0 +1,30 @@
1
+ require 'test/unit'
2
+ require 'stringio'
3
+ require 'tmpdir'
4
+ require 'bitclust'
5
+ require 'bitclust/subcommands/query_command'
6
+
7
+ class TestQueryCommand < Test::Unit::TestCase
8
+ def run_query(script, capi: false)
9
+ Dir.mktmpdir do |dir|
10
+ cmd = BitClust::Subcommands::QueryCommand.new
11
+ cmd.parse([script])
12
+ out = StringIO.new
13
+ orig, $stdout = $stdout, out
14
+ begin
15
+ cmd.exec([script], {:prefix => dir, :capi => capi})
16
+ ensure
17
+ $stdout = orig
18
+ end
19
+ out.string
20
+ end
21
+ end
22
+
23
+ def test_db_is_available_to_the_script
24
+ assert_equal %Q("BitClust::MethodDatabase"\n), run_query('@db.class.name')
25
+ end
26
+
27
+ def test_capi_uses_function_database
28
+ assert_equal %Q("BitClust::FunctionDatabase"\n), run_query('@db.class.name', capi: true)
29
+ end
30
+ end
@@ -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