bitclust-core 1.3.0 → 1.6.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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -0
  3. data/README.md +65 -0
  4. data/Rakefile +32 -0
  5. data/data/bitclust/catalog/ja_JP.UTF-8 +19 -1
  6. data/data/bitclust/searchpage/index.html +90 -0
  7. data/data/bitclust/template/class +69 -8
  8. data/data/bitclust/template/function +2 -2
  9. data/data/bitclust/template/layout +11 -3
  10. data/data/bitclust/template/method +2 -2
  11. data/data/bitclust/template/search +1 -1
  12. data/data/bitclust/template.epub/class +22 -1
  13. data/data/bitclust/template.epub/function +2 -2
  14. data/data/bitclust/template.epub/layout +1 -3
  15. data/data/bitclust/template.epub/method +2 -2
  16. data/data/bitclust/template.lillia/class +26 -5
  17. data/data/bitclust/template.lillia/layout +1 -3
  18. data/data/bitclust/template.lillia/method +2 -2
  19. data/data/bitclust/template.offline/class +48 -4
  20. data/data/bitclust/template.offline/function +2 -2
  21. data/data/bitclust/template.offline/layout +27 -3
  22. data/data/bitclust/template.offline/method +2 -2
  23. data/lib/bitclust/app.rb +16 -17
  24. data/lib/bitclust/capi_converter.rb +20 -0
  25. data/lib/bitclust/classentry.rb +67 -17
  26. data/lib/bitclust/compat.rb +2 -2
  27. data/lib/bitclust/completion.rb +58 -27
  28. data/lib/bitclust/crossrubyutils.rb +8 -5
  29. data/lib/bitclust/database.rb +28 -8
  30. data/lib/bitclust/doc_converter.rb +63 -0
  31. data/lib/bitclust/docentry.rb +1 -1
  32. data/lib/bitclust/entity_splitter.rb +221 -0
  33. data/lib/bitclust/entry.rb +47 -7
  34. data/lib/bitclust/functiondatabase.rb +13 -1
  35. data/lib/bitclust/functionentry.rb +5 -1
  36. data/lib/bitclust/functionreferenceparser.rb +4 -4
  37. data/lib/bitclust/generators/epub.rb +10 -3
  38. data/lib/bitclust/htmlutils.rb +1 -1
  39. data/lib/bitclust/include_graph.rb +375 -0
  40. data/lib/bitclust/include_pruner.rb +144 -0
  41. data/lib/bitclust/interface.rb +1 -1
  42. data/lib/bitclust/libraryentry.rb +8 -7
  43. data/lib/bitclust/lineinput.rb +7 -5
  44. data/lib/bitclust/link_checker.rb +179 -0
  45. data/lib/bitclust/markdown_bridge.rb +186 -0
  46. data/lib/bitclust/markdown_orchestrator.rb +267 -0
  47. data/lib/bitclust/markdown_to_rrd.rb +824 -0
  48. data/lib/bitclust/markdown_tree.rb +217 -0
  49. data/lib/bitclust/mdcompiler.rb +883 -0
  50. data/lib/bitclust/mdparser.rb +347 -0
  51. data/lib/bitclust/messagecatalog.rb +3 -3
  52. data/lib/bitclust/method_since_calculator.rb +117 -0
  53. data/lib/bitclust/methoddatabase.rb +124 -14
  54. data/lib/bitclust/methodentry.rb +125 -7
  55. data/lib/bitclust/methodid.rb +12 -11
  56. data/lib/bitclust/methodsignature.rb +10 -4
  57. data/lib/bitclust/nameutils.rb +41 -22
  58. data/lib/bitclust/preprocessor.rb +66 -29
  59. data/lib/bitclust/rdcompiler.rb +195 -95
  60. data/lib/bitclust/refsdatabase.rb +23 -8
  61. data/lib/bitclust/reloadable_request_handler.rb +72 -0
  62. data/lib/bitclust/requesthandler.rb +21 -16
  63. data/lib/bitclust/ridatabase.rb +10 -4
  64. data/lib/bitclust/rrd_to_markdown.rb +925 -0
  65. data/lib/bitclust/rrdparser.rb +196 -30
  66. data/lib/bitclust/runner.rb +17 -2
  67. data/lib/bitclust/screen.rb +80 -11
  68. data/lib/bitclust/search_index_generator.rb +288 -0
  69. data/lib/bitclust/searcher.rb +69 -20
  70. data/lib/bitclust/simplesearcher.rb +24 -12
  71. data/lib/bitclust/subcommand.rb +7 -1
  72. data/lib/bitclust/subcommands/ancestors_command.rb +9 -2
  73. data/lib/bitclust/subcommands/checklink_command.rb +54 -0
  74. data/lib/bitclust/subcommands/chm_command.rb +15 -7
  75. data/lib/bitclust/subcommands/classes_command.rb +5 -0
  76. data/lib/bitclust/subcommands/extract_command.rb +5 -0
  77. data/lib/bitclust/subcommands/htmlfile_command.rb +21 -7
  78. data/lib/bitclust/subcommands/init_command.rb +4 -1
  79. data/lib/bitclust/subcommands/list_command.rb +12 -4
  80. data/lib/bitclust/subcommands/lookup_command.rb +28 -9
  81. data/lib/bitclust/subcommands/methods_command.rb +10 -2
  82. data/lib/bitclust/subcommands/methodsince_command.rb +80 -0
  83. data/lib/bitclust/subcommands/preproc_command.rb +5 -0
  84. data/lib/bitclust/subcommands/property_command.rb +2 -1
  85. data/lib/bitclust/subcommands/searchpage_command.rb +102 -0
  86. data/lib/bitclust/subcommands/server_command.rb +10 -8
  87. data/lib/bitclust/subcommands/setup_command.rb +24 -10
  88. data/lib/bitclust/subcommands/statichtml_command.rb +129 -6
  89. data/lib/bitclust/subcommands/update_command.rb +110 -2
  90. data/lib/bitclust/syntax_highlighter.rb +48 -4
  91. data/lib/bitclust/textutils.rb +3 -3
  92. data/lib/bitclust/version.rb +1 -1
  93. data/lib/bitclust/version_badges.rb +90 -0
  94. data/lib/bitclust/whole_file_gate.rb +137 -0
  95. data/test/test_app.rb +269 -0
  96. data/test/test_bitclust.rb +2 -0
  97. data/test/test_capi_converter.rb +91 -0
  98. data/test/test_class_screen.rb +413 -0
  99. data/test/test_copy_doc_md.rb +53 -0
  100. data/test/test_doc_converter.rb +78 -0
  101. data/test/test_entity_splitter.rb +231 -0
  102. data/test/test_entry.rb +207 -2
  103. data/test/test_eol_warning.rb +43 -0
  104. data/test/test_functionreferenceparser.rb +11 -0
  105. data/test/test_include_graph.rb +640 -0
  106. data/test/test_include_pruner.rb +133 -0
  107. data/test/test_link_checker.rb +114 -0
  108. data/test/test_lookup_command.rb +135 -0
  109. data/test/test_markdown_bridge.rb +217 -0
  110. data/test/test_markdown_orchestrator.rb +478 -0
  111. data/test/test_markdown_to_rrd.rb +757 -0
  112. data/test/test_markdown_tree.rb +369 -0
  113. data/test/test_mdcompiler.rb +1276 -0
  114. data/test/test_mdparser.rb +538 -0
  115. data/test/test_method_screen.rb +166 -0
  116. data/test/test_method_since_calculator.rb +290 -0
  117. data/test/test_methoddatabase.rb +16 -0
  118. data/test/test_methodentry.rb +328 -0
  119. data/test/test_methodsignature.rb +3 -1
  120. data/test/test_methodsince_command.rb +168 -0
  121. data/test/test_nameutils.rb +22 -0
  122. data/test/test_preprocessor.rb +215 -0
  123. data/test/test_rdcompiler.rb +401 -30
  124. data/test/test_refsdatabase.rb +32 -0
  125. data/test/test_rrd_to_markdown.rb +968 -0
  126. data/test/test_rrdparser.rb +295 -2
  127. data/test/test_run_ruby_wasm.rb +54 -0
  128. data/test/test_run_worker_prelude.rb +83 -0
  129. data/test/test_runner.rb +51 -0
  130. data/test/test_search_index_generator.rb +513 -0
  131. data/test/test_search_screen.rb +48 -0
  132. data/test/test_searcher.rb +132 -0
  133. data/test/test_searchpage_command.rb +167 -0
  134. data/test/test_simplesearcher.rb +28 -0
  135. data/test/test_statichtml_command.rb +204 -0
  136. data/test/test_syntax_highlighter.rb +60 -0
  137. data/test/test_whole_file_gate.rb +175 -0
  138. data/theme/default/js/NOTICE +42 -0
  139. data/theme/default/js/run-worker.js +111 -0
  140. data/theme/default/js/run.js +350 -0
  141. data/theme/default/js/search_controller.js +134 -0
  142. data/theme/default/js/search_init.js +176 -0
  143. data/theme/default/js/search_navigation.js +110 -0
  144. data/theme/default/js/search_page.js +202 -0
  145. data/theme/default/js/search_ranker.js +244 -0
  146. data/theme/default/script.js +91 -18
  147. data/theme/default/search.css +149 -0
  148. data/theme/default/style.css +193 -21
  149. data/theme/default/syntax-highlight.css +8 -0
  150. data/theme/lillia/style.css +45 -1
  151. metadata +161 -20
  152. data/README +0 -32
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # bitclust/link_checker.rb
4
+ #
5
+ # DB 全体を走査して [[c:]]/[[m:]]/[[lib:]]/[[d:]]/[[f:]] 参照の
6
+ # リンク切れを検出する。bitclust の参照リンクは描画時に存在検証されない
7
+ # (URL を文字列変換で組むだけ)ため、壊れた参照は静かにリンク切れになる。
8
+ #
9
+ # 参照の抽出は正規表現の自前スキャンではなく、実際のコンパイル経路
10
+ # (RDCompiler#bracket_link に仕込んだフック @option[:link_checker])で
11
+ # 行う。これにより「描画時に本当にリンクになるものだけ」が対象になり、
12
+ # コードスパン・コードフェンス内の参照風テキストは自然に除外される。
13
+
14
+ require 'bitclust/compat'
15
+ require 'bitclust/nameutils'
16
+ require 'bitclust/methodid'
17
+ require 'bitclust/exception'
18
+
19
+ module BitClust
20
+
21
+ class LinkChecker
22
+
23
+ Finding = Struct.new(:location, :ref, :message)
24
+
25
+ # bracket_link が扱う型のうち、DB 外を指すので検証対象外のもの
26
+ EXTERNAL_TYPES = %w[url man rfc RFC ruby-list ruby-dev ruby-ext
27
+ ruby-talk ruby-core feature bug misc].freeze
28
+
29
+ def initialize(db, function_database: nil)
30
+ @db = db
31
+ @fdb = function_database
32
+ @findings = [] #: Array[Finding]
33
+ @current_location = nil #: String?
34
+ @skipped_function_refs = 0
35
+ end
36
+
37
+ attr_reader :findings
38
+ attr_reader :skipped_function_refs
39
+ attr_accessor :current_location
40
+
41
+ def broken_count
42
+ @findings.size
43
+ end
44
+
45
+ # DB 中の全ソース(ライブラリ・クラス・メソッド・doc ページ)を
46
+ # コンパイルして findings を収集する
47
+ def check_all
48
+ @db.libraries.sort_by(&:id).each do |lib|
49
+ check_source(lib.source_location || "library #{lib.name}") {|c| c.compile(lib.source.to_s) }
50
+ end
51
+ @db.classes.sort_by(&:id).each do |klass|
52
+ check_source(klass.source_location || "class #{klass.name}") {|c| c.compile(klass.source.to_s) }
53
+ klass.entries.sort_by(&:id).each do |m|
54
+ check_source(m.source_location || "#{klass.name} #{m.name}") {|c| c.compile_method(m) }
55
+ end
56
+ end
57
+ @db.docs.sort_by(&:id).each do |doc|
58
+ check_source(doc.source_location || "doc #{doc.name}") {|c| c.compile(doc.source.to_s) }
59
+ end
60
+ @findings
61
+ end
62
+
63
+ # RDCompiler#bracket_link から呼ばれるフック。描画には影響しない
64
+ def note_ref(type, arg)
65
+ case type
66
+ when 'c' then check_class(arg)
67
+ when 'm' then check_method(arg)
68
+ when 'lib' then check_library(arg)
69
+ when 'd' then check_doc(arg)
70
+ when 'f' then check_function(arg)
71
+ when 'ref' then check_ref(arg)
72
+ else
73
+ unless EXTERNAL_TYPES.include?(type)
74
+ # 未知の型は bracket_link がリテラル [[...]] のまま出力する。
75
+ # ほぼ確実に型名の書き間違いなので報告する
76
+ record(type, arg, 'unknown link type')
77
+ end
78
+ end
79
+ nil
80
+ end
81
+
82
+ private
83
+
84
+ def check_source(location)
85
+ # Location#to_s は行番号不明のとき "path:" になるので末尾の : を落とす
86
+ @current_location = location.to_s.sub(/:\z/, '')
87
+ compiler = new_compiler
88
+ begin
89
+ yield compiler
90
+ rescue => err
91
+ # コンパイル自体の失敗はリンク切れとは別問題(構文エラー等)。
92
+ # チェックを止めず、報告だけして続行する
93
+ record('compile', location.to_s, "compile failed: #{err.class}: #{err.message}")
94
+ end
95
+ ensure
96
+ @current_location = nil
97
+ end
98
+
99
+ def new_compiler
100
+ mapper_conf = Hash.new {|_h, _k| '' } #: untyped
101
+ urlmapper = URLMapper.new(mapper_conf)
102
+ opt = { :database => @db, :link_checker => self } #: RDCompiler::option
103
+ if @db.properties['source_format'] == 'markdown'
104
+ require 'bitclust/mdcompiler'
105
+ gfm_opt = opt.merge(:gfm => true) #: RDCompiler::option
106
+ MDCompiler.new(urlmapper, 1, gfm_opt)
107
+ else
108
+ require 'bitclust/rdcompiler'
109
+ RDCompiler.new(urlmapper, 1, opt)
110
+ end
111
+ end
112
+
113
+ def check_class(name)
114
+ @db.fetch_class(name)
115
+ rescue NotFoundError, InvalidKey
116
+ record('c', name, 'class not found')
117
+ end
118
+
119
+ def check_method(spec_str)
120
+ # rdcompiler の complete_spec と同じ補完($~ 等は Kernel の特殊変数)
121
+ str = spec_str.start_with?('$') ? "Kernel#{spec_str}" : spec_str
122
+ begin
123
+ spec = MethodSpec.parse(str)
124
+ rescue => _err
125
+ record('m', spec_str, 'malformed method spec')
126
+ return
127
+ end
128
+ # fetch_methods は不在時に空配列(truthy)を返してしまい raise しない
129
+ # ため、単数形の fetch_method(不在で MethodNotFound)を使う
130
+ @db.fetch_method(spec)
131
+ rescue NotFoundError, InvalidKey
132
+ record('m', spec_str, 'method not found')
133
+ end
134
+
135
+ def check_library(name)
136
+ return if name == '/' || name == '_index'
137
+ @db.fetch_library(name)
138
+ rescue NotFoundError, InvalidKey
139
+ record('lib', name, 'library not found')
140
+ end
141
+
142
+ def check_doc(name)
143
+ @db.fetch_doc(name)
144
+ rescue NotFoundError, InvalidKey
145
+ record('d', name, 'doc not found')
146
+ end
147
+
148
+ def check_function(name)
149
+ return if name == '/' || name == '_index' || name.empty?
150
+ fdb = @fdb
151
+ unless fdb
152
+ @skipped_function_refs += 1
153
+ return
154
+ end
155
+ fdb.fetch_function(name)
156
+ rescue NotFoundError, InvalidKey
157
+ record('f', name, 'function not found')
158
+ end
159
+
160
+ # [[ref:type:name#frag]] 形式のみ検証する(参照先本体の存在チェック)。
161
+ # エントリローカルの [[ref:frag]] 形式は対象外(entry 文脈が必要で、
162
+ # アンカー未定義でも描画は劣化表示に留まるため)
163
+ def check_ref(arg)
164
+ if /\A(\w+):(.*)\#[-\w]+\z/ =~ arg
165
+ type, name = $1, $2
166
+ case type
167
+ when 'c' then check_class(name || raise)
168
+ when 'm' then check_method(name || raise)
169
+ when 'lib' then check_library(name || raise)
170
+ when 'd' then check_doc(name || raise)
171
+ end
172
+ end
173
+ end
174
+
175
+ def record(type, arg, message)
176
+ @findings.push Finding.new(@current_location, "#{type}:#{arg}", message)
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'pathname'
5
+
6
+ require 'bitclust/markdown_tree'
7
+ require 'bitclust/markdown_to_rrd'
8
+
9
+ module BitClust
10
+ # md ツリー → 旧形式の rd ツリー(LIBRARIES + .rd)を生成するブリッジ。
11
+ #
12
+ # 既存の update 機構(LIBRARIES → RRDParser/Preprocessor)を一切変更せずに
13
+ # md ツリーから DB を組み立てるための変換層。MarkdownTree の発見結果から:
14
+ # - LIBRARIES を再生成(ライブラリの since/until は #@ ゲートとして再具現化)
15
+ # - ライブラリ .rd = 変換済み本文 + メンバーへの #@include を再生成
16
+ # - メンバー = 変換済み本文を front matter の since/until で #@ ラップ
17
+ # (版指定ビルドで構造ゲートが効くように)
18
+ # - 断片 = そのまま変換
19
+ # 出力ファイル名は「md 名から .md を剥いだもの」(ライブラリのみ .rd 付き)に
20
+ # 統一し、本文中の #@include ターゲットも emit 名へ書き換える
21
+ # (Preprocessor はリテラルパスで解決するため)。
22
+ class MarkdownBridge
23
+ INCLUDE_RE = /^(\#@include\s*\()(.*?)(\))/
24
+
25
+ def self.build(md_root, out_root)
26
+ new(md_root, out_root).build
27
+ end
28
+
29
+ # doc(散文ページ)の md ツリー → 旧形式の doc/*.rd。
30
+ # - クロスツリー include(api 断片の transclude)は旧レイアウト
31
+ # (../api/src/)へ戻す(ブリッジの api ツリーは api/src に置かれるため)
32
+ # - doc 内ローカル include から参照される断片(spec/regexp19 等)は
33
+ # 拡張子なしで emit する(copy_doc は **/*.rd だけをページとして読む)
34
+ # 戻り値は source_map(出力相対パス => 入力 md 相対パス)
35
+ def self.build_doc(md_doc_root, out_doc_root)
36
+ require 'bitclust/markdown_to_rrd'
37
+ files = Dir.glob('**/*.md', base: md_doc_root).sort
38
+ referenced = files.flat_map { |f|
39
+ base = File.dirname(f)
40
+ File.read(File.join(md_doc_root, f)).scan(/^\#@include\((?!(?:\.\.\/)+api\/)(.*?)\)/)
41
+ .map { |t| File.expand_path(base == '.' ? (t[0] || raise) : File.join(base, t[0] || raise), '/')
42
+ .delete_prefix('/') }
43
+ }.to_h { |t| [t, true] }
44
+
45
+ files.to_h do |f|
46
+ rrd = MarkdownToRRD.convert(File.read(File.join(md_doc_root, f)))
47
+ rrd = rrd.gsub(%r{^(\#@include\((?:\.\./)+api/)(?!src/)}, '\1src/')
48
+ name = f.sub(/\.md\z/, '')
49
+ rel = referenced[name] ? name : "#{name}.rd"
50
+ full = File.join(out_doc_root, rel)
51
+ FileUtils.mkdir_p(File.dirname(full))
52
+ File.write(full, rrd)
53
+ [rel, f]
54
+ end
55
+ end
56
+
57
+ attr_reader :warnings
58
+
59
+ # 出力相対パス(emit 名) => 入力 md 相対パス。
60
+ # source_location を manual/ の md へ再マップするために使う
61
+ attr_reader :source_map
62
+
63
+ def initialize(md_root, out_root)
64
+ @md_root = md_root
65
+ @out_root = out_root
66
+ @tree = MarkdownTree.scan(md_root)
67
+ @warnings = @tree.warnings.dup
68
+ @source_map = {}
69
+ # 名前はパス由来とは限らない(ファイル名衝突回避で改名された
70
+ # rdoc/rdoc.lib.md は front matter の name: が正)ので、パスから引く
71
+ @lib_by_path = @tree.libraries.to_h { |name, lib| [lib[:path], name] }
72
+ end
73
+
74
+ def build
75
+ files = Dir.glob('**/*.md', base: @md_root).sort
76
+ @dirs = files.flat_map { |f|
77
+ parts = File.dirname(f).split('/')
78
+ (1..parts.size).map { |n| parts.first(n).join('/') }
79
+ }.uniq - ['.']
80
+ emitted = files.to_h { |f| [f, emit_name(f)] }
81
+ @source_map = emitted.invert
82
+
83
+ files.each do |f|
84
+ rrd = MarkdownToRRD.convert(File.read(File.join(@md_root, f)))
85
+ rrd = rewrite_includes(rrd, f, emitted)
86
+ if (libname = @lib_by_path[f])
87
+ rrd << member_includes(libname, emitted)
88
+ elsif (entity = @tree.entities[f])
89
+ rrd = wrap_gate(rrd, entity)
90
+ end
91
+ write(emitted[f], rrd)
92
+ end
93
+ write('LIBRARIES', libraries_manifest)
94
+ self
95
+ end
96
+
97
+ private
98
+
99
+ # ライブラリは <name>.rd(update_by_stdlibtree の解決規約)。
100
+ # 非ライブラリは拡張子なし(doc ツリーが `../api/src/_builtin/pack-template` の
101
+ # ように元式の拡張子なし名で断片を transclude しているため)。
102
+ # ただし同名ディレクトリと衝突する場合は .rd を付ける(scanf.md と scanf/)
103
+ def emit_name(f)
104
+ return "#{@lib_by_path[f]}.rd" if @lib_by_path[f]
105
+ name = f.sub(/\.md\z/, '')
106
+ return "#{name}.rd" if @dirs.include?(name)
107
+ name
108
+ end
109
+
110
+ # LIBRARIES: 名前順。ライブラリの版ゲートは #@ で再具現化する
111
+ def libraries_manifest
112
+ @tree.libraries.sort.map { |name, lib|
113
+ entry = "#{name}\n"
114
+ entry = "\#@since #{lib[:since]}\n#{entry}\#@end\n" if lib[:since]
115
+ entry = "\#@until #{lib[:until]}\n#{entry}\#@end\n" if lib[:until]
116
+ entry
117
+ }.join
118
+ end
119
+
120
+ # ライブラリのメンバー(membership がこのライブラリを指すエンティティ)への
121
+ # #@include をライブラリ .rd のディレクトリ相対で再生成する。
122
+ # reopen/redefine だけのファイルは後ろに並べる(reopen が dynamic include する
123
+ # module は同ライブラリ内で先に定義されている必要がある: json/rake)。
124
+ # 多重所属(ゲート付き library リスト)は membership のゲートで
125
+ # include サイトをラップする(旧 LIBRARIES 世界のゲート付き include と同義)
126
+ def member_includes(libname, emitted)
127
+ base = File.dirname("#{libname}.rd")
128
+ members = @tree.entities.select { |path, e|
129
+ e[:memberships].any? { |m| m[:library] == libname }
130
+ }
131
+ return '' if members.empty?
132
+
133
+ sorted = members.keys.sort_by do |path|
134
+ reopen_only = members[path][:kinds].all? { |kind, _| %w[reopen redefine].include?(kind) }
135
+ [reopen_only ? 1 : 0, path]
136
+ end
137
+ "\n" + sorted.map { |path|
138
+ m = members[path][:memberships].find { |mm| mm[:library] == libname } || raise
139
+ inc = "\#@include(#{relative(emitted[path], base)})\n"
140
+ inc = "\#@since #{m[:since]}\n#{inc}\#@end\n" if m[:since]
141
+ inc = "\#@until #{m[:until]}\n#{inc}\#@end\n" if m[:until]
142
+ inc
143
+ }.join
144
+ end
145
+
146
+ # front matter の構造ゲート(since/until)を #@ ラッパーとして再具現化する
147
+ def wrap_gate(rrd, entity)
148
+ rrd = "\#@since #{entity[:since]}\n#{rrd}\#@end\n" if entity[:since]
149
+ rrd = "\#@until #{entity[:until]}\n#{rrd}\#@end\n" if entity[:until]
150
+ rrd
151
+ end
152
+
153
+ # 本文中の #@include ターゲットを emit 名(相対)へ書き換える。
154
+ # 解決できないターゲットはそのまま(発見段階で警告済み)
155
+ def rewrite_includes(rrd, md_file, emitted)
156
+ return rrd unless rrd.include?('#@include')
157
+ base = File.dirname(md_file)
158
+ rrd.gsub(INCLUDE_RE) do
159
+ pre, target, post = $1, ($2 || raise), $3
160
+ resolved = resolve(base, target, emitted)
161
+ "#{pre}#{resolved ? relative(emitted[resolved], File.dirname(emitted[md_file])) : target}#{post}"
162
+ end
163
+ end
164
+
165
+ def resolve(base, target, emitted)
166
+ [target, "#{target}.md", "#{target.sub(/\.rd\z/, '')}.md"].uniq.each do |cand|
167
+ rel = base == '.' ? cand : File.join(base, cand)
168
+ rel = File.expand_path(rel, '/').delete_prefix('/')
169
+ return rel if emitted.key?(rel)
170
+ end
171
+ nil
172
+ end
173
+
174
+ def relative(path, from_dir)
175
+ return path if from_dir == '.'
176
+ Pathname.new(File.expand_path(path, '/'))
177
+ .relative_path_from(File.expand_path(from_dir, '/')).to_s
178
+ end
179
+
180
+ def write(rel, content)
181
+ full = File.join(@out_root, rel)
182
+ FileUtils.mkdir_p(File.dirname(full))
183
+ File.write(full, content)
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ require 'bitclust/include_graph'
6
+ require 'bitclust/rrd_to_markdown'
7
+ require 'bitclust/include_pruner'
8
+ require 'bitclust/whole_file_gate'
9
+ require 'bitclust/entity_splitter'
10
+ require 'bitclust/rrd_to_markdown'
11
+
12
+ module BitClust
13
+ # RD ツリー → Markdown ツリー変換のクロスファイル方針を束ねるオーケストレータ。
14
+ #
15
+ # 単一ファイル記法変換(RRDToMarkdown)はそのままに、include グラフの解析結果から
16
+ # 各ファイルへ次を適用する:
17
+ # - grouping include の prune(エンティティ取り込みは front matter 発見へ移行)
18
+ # - ファイル全体を包む版ゲートの解除(front matter の since/until へ移行)
19
+ # - front matter 注入(member: library/構造ゲート、library ルート: type/版ゲート)
20
+ #
21
+ # スコープ(対象版範囲)はパラメータ。旧版サルベージ時は別スコープで再実行する。
22
+ class MarkdownOrchestrator
23
+ attr_reader :scope, :graph
24
+
25
+ def initialize(src_root, scope: IncludeGraph::Scope.new('3.0', '4.2'))
26
+ @graph = IncludeGraph.analyze(src_root)
27
+ @scope = scope
28
+ @extra = @graph.front_matter_map(scope)
29
+ @graph.library_front_matter_map(scope).each do |path, fm|
30
+ (@extra[path] ||= {}).merge!(fm)
31
+ end
32
+ @prune_sites = @graph.grouping_include_sites
33
+ end
34
+
35
+ def warnings
36
+ @graph.warnings
37
+ end
38
+
39
+ # 変換対象か。LIBRARIES は front matter による発見に置き換わるため対象外
40
+ def convert?(relpath)
41
+ relpath != 'LIBRARIES'
42
+ end
43
+
44
+ # 1つの出力 .md ファイルに対応する単位。
45
+ # path = 出力相対パス、rrd = rd 側到達点(md→rd 検証の期待値)、
46
+ # front_matter = 注入する front matter
47
+ Unit = Struct.new(:path, :rrd, :front_matter)
48
+
49
+ # relpath の RD を出力単位の列に還元する。
50
+ # ヘッダ関係(include/extend/alias)を持つマルチエンティティファイルは
51
+ # エンティティ単位に分割する(関係の front matter 一元化のため)。
52
+ # 関係を持たない束ね(Errno 族等)と lib+単一エンティティ兼用ファイルは
53
+ # 1 単位のまま。ライブラリファイルの分割ではエンティティを <libname>/ 配下に
54
+ # 置き、library と版ゲートを注入する
55
+ def units(relpath, rrd)
56
+ reduced, front_matter = reduce(relpath, rrd)
57
+ # スコープ外ファイル(front matter 注入なし)は分割しない。
58
+ # サルベージは別スコープの再実行で扱う
59
+ segments = front_matter.empty? ? nil : split_segments(reduced)
60
+ return [Unit.new(output_path(relpath, front_matter), reduced, front_matter)] unless segments
61
+
62
+ library = front_matter['type'] == 'library'
63
+ dir = library ? relpath.sub(/\.rd\z/, '') : File.dirname(relpath)
64
+ entity_fm =
65
+ if library
66
+ fm = { 'library' => relpath.sub(/\.rd\z/, '') } #: IncludeGraph::front_matter
67
+ %w[since until].each { |k| fm[k] = front_matter[k] if front_matter[k] }
68
+ fm
69
+ else
70
+ front_matter
71
+ end
72
+
73
+ source_dir = File.dirname(relpath)
74
+ units = segments.map do |name, text|
75
+ next Unit.new(output_path(relpath, front_matter), text, front_matter) if name.nil? # 概要部
76
+
77
+ seg_fm = entity_fm.dup
78
+ if (unwrapped = WholeFileGate.unwrap_for_scope(text, @scope))
79
+ # エンティティセグメントのゲートは自身の存在ゲート → 常に front matter へ
80
+ text = unwrapped[0]
81
+ merge_gate(seg_fm, unwrapped[1])
82
+ end
83
+ text = rewrite_includes(text, source_dir, dir)
84
+ filename = "#{EntitySplitter.entity_filename(name)}.md"
85
+ Unit.new(dir == '.' ? filename : File.join(dir, filename), text, seg_fm)
86
+ end
87
+ if library && segments.none? { |name, _| name.nil? }
88
+ # 概要部が無くてもライブラリ自体が発見から消えないよう front matter のみ合成
89
+ units.unshift(Unit.new(output_path(relpath, front_matter), '', front_matter))
90
+ end
91
+ units
92
+ end
93
+
94
+ def convert_unit(unit)
95
+ RRDToMarkdown.convert(unit.rrd, extra_front_matter: unit.front_matter)
96
+ end
97
+
98
+ # relpath の RD を新パイプライン形の Markdown へ変換する(分割なしファイル用)
99
+ def convert(relpath, rrd)
100
+ us = units(relpath, rrd)
101
+ raise ArgumentError, "#{relpath} splits into #{us.size} files, use units" if us.size > 1
102
+ convert_unit(us.first)
103
+ end
104
+
105
+ # 変換の rd 側到達点(prune + 全体ゲート解除 + 定数 H1 ゲート解決後の RD)と
106
+ # front matter。MarkdownToRRD.convert(convert(...)) はこの RD と一致する
107
+ def reduce(relpath, rrd)
108
+ front_matter = (@extra[relpath] || {}).dup
109
+ rrd = IncludePruner.prune(rrd, @prune_sites[relpath] || [])
110
+ if (unwrapped = WholeFileGate.unwrap_for_scope(rrd, @scope))
111
+ if unwrap_allowed?(front_matter, unwrapped[1])
112
+ rrd = unwrapped[0]
113
+ merge_gate(front_matter, unwrapped[1])
114
+ elsif front_matter['type'] == 'library' &&
115
+ (regated = WholeFileGate.regate_metadata(rrd))
116
+ # 据え置きゲートの library ルート: メタデータ領域を独立ゲートに分離して
117
+ # front matter 化できるようにする(本文はゲートのまま)
118
+ rrd = regated
119
+ end
120
+ end
121
+ rrd = EntitySplitter.resolve_header_gates(rrd, @scope)
122
+ rrd = normalize_entity_h1(rrd)
123
+ rrd = normalize_signature_spacing(rrd)
124
+ rrd = RRDToMarkdown.normalize_dlist_colon_spacing(rrd)
125
+ rrd = rrd.sub(/\A(?:[ \t]*\n)+/, '') # 先頭空行(ゲート解決の残り)を除去
126
+ [normalize_header_regions(rrd), front_matter]
127
+ end
128
+
129
+ private
130
+
131
+ # 分割すべきならセグメント列を、そうでなければ nil を返す。
132
+ # 条件: エンティティ(名前付きセグメント)が2つ以上 + いずれかがヘッダ関係を持つ。
133
+ # lib+単一エンティティ兼用(pathname 型、仕様が認める形)は分割しない
134
+ def split_segments(reduced)
135
+ return nil unless EntitySplitter.header_relations?(reduced)
136
+ segments = EntitySplitter.segments(reduced)
137
+ return nil unless segments
138
+ segments.count { |name, _| name } >= 2 ? segments : nil
139
+ end
140
+
141
+ # セグメントの置き場所が元ファイルと異なるディレクトリになる場合
142
+ # (lib 分割 → <libname>/ 配下)、相対 #@include を新ディレクトリから
143
+ # 解決できるよう書き換える
144
+ def rewrite_includes(text, source_dir, new_dir)
145
+ return text if source_dir == new_dir || text !~ /\#@include/
146
+ base = File.expand_path(new_dir, '/')
147
+ text.gsub(/^(\#@include\s*\()(.*?)(\))/) do
148
+ pre, target, post = $1, ($2 || raise), $3
149
+ abs = File.expand_path(source_dir == '.' ? target : File.join(source_dir, target), '/')
150
+ "#{pre}#{Pathname.new(abs).relative_path_from(base)}#{post}"
151
+ end
152
+ end
153
+
154
+ # 出力 .md の相対パス(.rd は差し替え、その他は付加)
155
+ def md_path(relpath)
156
+ relpath.end_with?('.rd') ? relpath.sub(/\.rd\z/, '.md') : "#{relpath}.md"
157
+ end
158
+
159
+ # ライブラリファイルの出力パス。他ファイルの出力と大文字小文字のみで
160
+ # 衝突する場合(rdoc/rdoc.md と rdoc/RDoc.md)は basename に .lib を挟んで
161
+ # 回避する(macOS/Windows の case-insensitive FS でチェックアウト不能に
162
+ # なるため)。名前がパスから導出できなくなるので front matter の name: で保持する
163
+ def output_path(relpath, front_matter)
164
+ path = md_path(relpath)
165
+ return path unless front_matter['type'] == 'library'
166
+ collides = @extra.keys.any? { |other|
167
+ next false if other == relpath
168
+ op = md_path(other)
169
+ op != path && op.casecmp?(path)
170
+ }
171
+ return path unless collides
172
+ front_matter['name'] = relpath.sub(/\.rd\z/, '')
173
+ path.sub(/\.md\z/, '.lib.md')
174
+ end
175
+
176
+ RELATION_RE = /\A(?:include|extend|alias)\s+\S/
177
+ HEADER_DIR_RE = /\A\#@(?:since|until|if|else|end)\b/
178
+ BLANK_LINE_RE = /\A\s*\z/
179
+
180
+ # H1 直後のヘッダ関係領域を md→rd の再生成形に正規化する:
181
+ # 最後の関係行までの空行を除き、関係行の末尾空白を落とす。
182
+ # 関係を持たない H1 の直後や本文の空行・散文ゲートは触らない
183
+ def normalize_header_regions(rrd)
184
+ lines = rrd.lines
185
+ out = [] #: Array[String]
186
+ i = 0
187
+ while i < lines.length
188
+ line = lines[i]
189
+ out << line
190
+ i += 1
191
+ next unless line =~ EntitySplitter::H1_RE
192
+ region = [] #: Array[String]
193
+ while i < lines.length &&
194
+ (lines[i] =~ RELATION_RE || lines[i] =~ HEADER_DIR_RE || lines[i] =~ BLANK_LINE_RE)
195
+ region << lines[i]
196
+ i += 1
197
+ end
198
+ last_rel = region.rindex { |l| l =~ RELATION_RE }
199
+ if last_rel
200
+ head = (region[0..last_rel] || raise).reject { |l| l =~ BLANK_LINE_RE }
201
+ .map { |l| l =~ RELATION_RE ? "#{l.rstrip}\n" : l }
202
+ out.concat(head)
203
+ out.concat(region[(last_rel + 1)..] || raise)
204
+ else
205
+ out.concat(region)
206
+ end
207
+ end
208
+ out.join
209
+ end
210
+
211
+ # 「=class Encoding」のようなスペース無し H1(RRDParser は受理)を
212
+ # 正規形「= class」に直す。単一ファイル変換器は正規形のみ扱う
213
+ def normalize_entity_h1(rrd)
214
+ return rrd unless rrd =~ /^=(?:class|module|object|reopen|redefine)\b/
215
+ rrd.gsub(/^=((?:class|module|object|reopen|redefine)\b)/, '= \1')
216
+ end
217
+
218
+ # 「---critical=(bool)」のようなスペース無しシグネチャ(RDCompiler は
219
+ # /\A---/ で受理)を正規形「--- name」に直す。コードブロック内に
220
+ # 行頭 --- は現れない前提(roundtrip 検証が破れを検出する)
221
+ def normalize_signature_spacing(rrd)
222
+ rrd.gsub(/^---(?=[^\s-])/, '--- ')
223
+ end
224
+
225
+ # ファイル全体ゲートを front matter へ解除してよいか。
226
+ # - 常真ゲート(gate == {}): 常に解除(従来どおり)
227
+ # - 断片・スコープ外(front matter なし): 据え置き。front matter を付けると
228
+ # #@include 展開の途中に `---` が現れてパースが壊れる(_builtin/Fiber.current)
229
+ # - type: library ルート: LIBRARIES 由来境界に包含される場合のみ(下記)
230
+ # - メンバー: エンティティ自身の存在ゲートなので常に解除
231
+ def unwrap_allowed?(front_matter, gate)
232
+ return true if gate.empty?
233
+ return false if front_matter.empty?
234
+ return true unless front_matter['type'] == 'library'
235
+ gate_subsumed?(front_matter, gate)
236
+ end
237
+
238
+ # ライブラリルートのファイル全体ゲートが LIBRARIES 由来の既存境界に
239
+ # 包含されるか。包含されないゲート(cmath: LIBRARIES は until のみ +
240
+ # ファイルは #@since 1.9.1)をマージすると「ライブラリの存在」まで
241
+ # 狭めてしまう(旧世界ではスコープ下限の版にも存在して内容が空)
242
+ def gate_subsumed?(front_matter, gate)
243
+ gate.all? { |kind, v|
244
+ cur = front_matter[kind.to_s]
245
+ next false unless cur
246
+ if kind == :since
247
+ Gem::Version.new(cur) >= Gem::Version.new(v)
248
+ else
249
+ Gem::Version.new(cur) <= Gem::Version.new(v)
250
+ end
251
+ }
252
+ end
253
+
254
+ # ファイル全体ゲートと注入済みゲート(include サイト / LIBRARIES 由来)の交差を取る
255
+ # (since は max、until は min。両者は同じ制約の別表現なので通常は一致する)
256
+ def merge_gate(front_matter, gate)
257
+ if (v = gate[:since])
258
+ front_matter['since'] = [front_matter['since'], v].compact
259
+ .max_by { |x| Gem::Version.new(x) }
260
+ end
261
+ if (v = gate[:until])
262
+ front_matter['until'] = [front_matter['until'], v].compact
263
+ .min_by { |x| Gem::Version.new(x) }
264
+ end
265
+ end
266
+ end
267
+ end