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.
- checksums.yaml +4 -4
- data/data/bitclust/template/class-index +0 -1
- 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/lib/bitclust/generators/epub.rb +69 -5
- data/lib/bitclust/irb.rb +14 -8
- data/lib/bitclust/markdown_bridge.rb +11 -6
- data/lib/bitclust/markdown_tree.rb +118 -11
- data/lib/bitclust/methoddatabase.rb +17 -4
- data/lib/bitclust/nameutils.rb +13 -0
- data/lib/bitclust/preprocessor.rb +0 -36
- data/lib/bitclust/remote.rb +405 -0
- data/lib/bitclust/runner.rb +5 -10
- data/lib/bitclust/screen.rb +2 -1
- data/lib/bitclust/searcher.rb +43 -24
- 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/server_command.rb +21 -10
- data/lib/bitclust/subcommands/setup_command.rb +28 -10
- data/lib/bitclust/syntax_highlighter.rb +65 -4
- data/lib/bitclust/user_dirs.rb +62 -0
- data/lib/bitclust/version.rb +1 -1
- 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 +100 -6
- data/test/test_markdown_bridge.rb +35 -0
- data/test/test_markdown_tree.rb +144 -0
- data/test/test_mdparser.rb +39 -0
- data/test/test_methods_command.rb +32 -0
- data/test/test_opensearchdescription_screen.rb +39 -0
- data/test/test_query_command.rb +30 -0
- data/test/test_remote.rb +374 -0
- data/test/test_runner.rb +61 -1
- data/test/test_searcher.rb +63 -0
- data/test/test_server_command.rb +41 -0
- data/test/test_setup_command.rb +143 -0
- data/test/test_syntax_highlighter.rb +124 -0
- data/test/test_user_dirs.rb +62 -0
- metadata +37 -2
- data/lib/bitclust/ridatabase.rb +0 -359
data/test/test_markdown_tree.rb
CHANGED
|
@@ -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
|
data/test/test_mdparser.rb
CHANGED
|
@@ -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
|
data/test/test_remote.rb
ADDED
|
@@ -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
|