rufio 1.0.4 → 1.3.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.md +105 -2
  4. data/README_ja.md +103 -2
  5. data/Rakefile +14 -1
  6. data/bin/rufio +31 -2
  7. data/docs/fast_zip_design_ja.md +167 -0
  8. data/examples/config.rb +8 -0
  9. data/info/help.md +2 -0
  10. data/info/keybindings.md +3 -1
  11. data/info/welcome.md +1 -1
  12. data/lib/rufio/application.rb +12 -1
  13. data/lib/rufio/archive_controller.rb +211 -0
  14. data/lib/rufio/background_command_executor.rb +9 -0
  15. data/lib/rufio/background_status.rb +49 -0
  16. data/lib/rufio/bookmark_controller.rb +38 -121
  17. data/lib/rufio/bookmark_manager.rb +77 -331
  18. data/lib/rufio/command_completion.rb +6 -1
  19. data/lib/rufio/command_history.rb +5 -2
  20. data/lib/rufio/command_mode.rb +40 -134
  21. data/lib/rufio/command_mode_ui.rb +4 -42
  22. data/lib/rufio/config.rb +89 -137
  23. data/lib/rufio/config_loader.rb +21 -13
  24. data/lib/rufio/dialog_renderer.rb +64 -152
  25. data/lib/rufio/dialog_support.rb +69 -0
  26. data/lib/rufio/document_text_extractor.rb +167 -0
  27. data/lib/rufio/file_operation_controller.rb +49 -222
  28. data/lib/rufio/file_preview.rb +46 -2
  29. data/lib/rufio/global_search.rb +373 -0
  30. data/lib/rufio/help_menu.rb +27 -2
  31. data/lib/rufio/interpreter_resolver.rb +6 -11
  32. data/lib/rufio/job_manager.rb +2 -5
  33. data/lib/rufio/key_input.rb +77 -0
  34. data/lib/rufio/keybind_handler.rb +120 -112
  35. data/lib/rufio/local_script_scanner.rb +6 -18
  36. data/lib/rufio/native/aarch64-linux-gnu/librufio_zig.so +0 -0
  37. data/lib/rufio/native/aarch64-linux-musl/librufio_zig.so +0 -0
  38. data/lib/rufio/native/aarch64-macos/librufio_zig.dylib +0 -0
  39. data/lib/rufio/native/aarch64-windows/rufio_zig.dll +0 -0
  40. data/lib/rufio/native/x86_64-linux-gnu/librufio_zig.so +0 -0
  41. data/lib/rufio/native/x86_64-linux-musl/librufio_zig.so +0 -0
  42. data/lib/rufio/native/x86_64-macos/librufio_zig.dylib +0 -0
  43. data/lib/rufio/native/x86_64-windows/rufio_zig.dll +0 -0
  44. data/lib/rufio/native_content_search.rb +153 -0
  45. data/lib/rufio/native_scanner.rb +13 -68
  46. data/lib/rufio/native_scanner_zig.rb +106 -56
  47. data/lib/rufio/native_zip.rb +121 -0
  48. data/lib/rufio/navigation_controller.rb +18 -20
  49. data/lib/rufio/screen.rb +2 -12
  50. data/lib/rufio/script_executor.rb +6 -73
  51. data/lib/rufio/script_file.rb +44 -0
  52. data/lib/rufio/script_path_manager.rb +9 -43
  53. data/lib/rufio/script_runner.rb +13 -50
  54. data/lib/rufio/search_controller.rb +1 -1
  55. data/lib/rufio/shell_command_completion.rb +1 -5
  56. data/lib/rufio/shell_init.rb +90 -0
  57. data/lib/rufio/tab_mode_manager.rb +5 -1
  58. data/lib/rufio/task_status.rb +5 -1
  59. data/lib/rufio/terminal_ui.rb +116 -293
  60. data/lib/rufio/text_utils.rb +26 -3
  61. data/lib/rufio/ui_renderer.rb +68 -41
  62. data/lib/rufio/version.rb +1 -1
  63. data/lib/rufio/zip_archive.rb +314 -0
  64. data/lib/rufio/zip_extractor.rb +346 -0
  65. data/lib/rufio/zip_writer.rb +268 -0
  66. data/lib/rufio/zoxide_integration.rb +5 -43
  67. data/lib/rufio.rb +5 -0
  68. data/lib_zig/rufio_native/Makefile +54 -17
  69. data/lib_zig/rufio_native/bench.sh +36 -0
  70. data/lib_zig/rufio_native/build.zig +32 -35
  71. data/lib_zig/rufio_native/src/cli.zig +99 -0
  72. data/lib_zig/rufio_native/src/main.zig +187 -83
  73. data/lib_zig/rufio_native/src/os.zig +889 -0
  74. data/lib_zig/rufio_native/src/search.zig +919 -0
  75. data/lib_zig/rufio_native/src/search_test.zig +88 -0
  76. data/lib_zig/rufio_native/src/zip.zig +482 -0
  77. metadata +126 -3
  78. data/lib/rufio/native/rufio_zig.bundle +0 -0
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'dialog_support'
4
+
3
5
  require 'shellwords'
4
6
  require_relative 'file_opener'
5
7
  require_relative 'filter_manager'
@@ -11,11 +13,14 @@ require_relative 'selection_manager'
11
13
  require_relative 'file_operations'
12
14
  require_relative 'bookmark_manager'
13
15
  require_relative 'zoxide_integration'
16
+ require_relative 'archive_controller'
14
17
  require_relative 'dialog_renderer'
15
18
  require_relative 'logger'
16
19
 
17
20
  module Rufio
18
21
  class KeybindHandler
22
+ include DialogSupport
23
+
19
24
  # NavigationController への委譲
20
25
  def current_index
21
26
  @nav_controller.current_index
@@ -25,6 +30,55 @@ module Rufio
25
30
  @nav_controller.preview_focused?
26
31
  end
27
32
 
33
+ def global_search_mode?
34
+ @terminal_ui&.global_search_active? || false
35
+ end
36
+
37
+ def start_global_search
38
+ @terminal_ui&.start_global_search(@bookmark_manager.list)
39
+ end
40
+
41
+ def config_directory
42
+ Config::CONFIG_DIR
43
+ end
44
+
45
+ def config_mode?
46
+ return false if @in_log_viewer_mode || @in_help_mode || @job_mode.active?
47
+
48
+ path = @directory_listing&.current_path
49
+ root = File.expand_path(config_directory)
50
+ path && (path == root || path.start_with?(root + File::SEPARATOR))
51
+ end
52
+
53
+ def enter_config_mode
54
+ return false unless @directory_listing
55
+
56
+ FileUtils.mkdir_p(config_directory)
57
+ leave_other_modes(:config)
58
+ @nav_controller.unfocus_preview_pane
59
+ navigate_to_directory(config_directory)
60
+ rescue SystemCallError => e
61
+ @notification_manager.add("Cannot open config directory: #{e.message}", :error, duration: 0)
62
+ false
63
+ end
64
+
65
+ # モード遷移の入口。ヘルプ・検索のオーバーレイは元のモードを保持する。
66
+ def switch_mode(mode)
67
+ case mode
68
+ when :files then leave_other_modes(:files)
69
+ when :logs then enter_log_viewer_mode
70
+ when :jobs then enter_job_mode
71
+ when :config then enter_config_mode
72
+ when :global then start_global_search
73
+ when :help then show_help_dialog
74
+ else false
75
+ end
76
+ end
77
+
78
+ def navigate_to_directory(path)
79
+ @nav_controller.navigate_to_directory(path)
80
+ end
81
+
28
82
  def preview_scroll_offset
29
83
  @nav_controller.preview_scroll_offset
30
84
  end
@@ -78,6 +132,11 @@ module Rufio
78
132
  @script_path_manager, @notification_manager, @zoxide_integration
79
133
  )
80
134
 
135
+ # ArchiveController(選択アイテムの ZIP 圧縮・展開を担当)
136
+ @archive_controller = ArchiveController.new(
137
+ nil, @dialog_renderer, @selection_manager, @job_manager, @notification_manager
138
+ )
139
+
81
140
  # SearchController(fzf・rga検索を担当)
82
141
  @search_controller = SearchController.new(nil, @file_opener)
83
142
 
@@ -100,12 +159,14 @@ module Rufio
100
159
  @file_op_controller.set_directory_listing(directory_listing)
101
160
  @bookmark_controller.set_directory_listing(directory_listing)
102
161
  @search_controller.set_directory_listing(directory_listing)
162
+ @archive_controller.set_directory_listing(directory_listing)
103
163
  end
104
164
 
105
165
  def set_terminal_ui(terminal_ui)
106
166
  @terminal_ui = terminal_ui
107
167
  @nav_controller.set_terminal_ui(terminal_ui)
108
168
  @file_op_controller.set_terminal_ui(terminal_ui)
169
+ @archive_controller.set_terminal_ui(terminal_ui)
109
170
  @bookmark_controller.set_terminal_ui(terminal_ui)
110
171
  # terminal_ui が設定されたら、bookmark_manager と zoxide_integration にも渡す
111
172
  @bookmark_manager.set_terminal_ui(terminal_ui)
@@ -134,6 +195,14 @@ module Rufio
134
195
  return show_help_dialog
135
196
  end
136
197
 
198
+ if @key_map[key] == :global_search && !@filter_manager.filter_mode
199
+ return start_global_search
200
+ end
201
+
202
+ if @key_map[key] == :config_mode && !@filter_manager.filter_mode
203
+ return enter_config_mode
204
+ end
205
+
137
206
  # ジョブモード中の特別処理
138
207
  if @job_mode.active?
139
208
  return handle_job_mode_key(key)
@@ -194,6 +263,10 @@ module Rufio
194
263
  entries[@nav_controller.current_index]
195
264
  end
196
265
 
266
+ def filter_mode?
267
+ @filter_manager.filter_mode
268
+ end
269
+
197
270
  def filter_active?
198
271
  @filter_manager.filter_active?
199
272
  end
@@ -223,11 +296,6 @@ module Rufio
223
296
  return false unless @directory_listing
224
297
  return true if @in_help_mode
225
298
 
226
- exit_log_viewer_mode if @in_log_viewer_mode
227
-
228
- # 現在のディレクトリを保存
229
- @pre_help_directory = @directory_listing.current_path
230
-
231
299
  # info ディレクトリに移動
232
300
  rufio_root = File.expand_path('../..', __dir__)
233
301
  info_dir = File.join(rufio_root, 'info')
@@ -235,6 +303,9 @@ module Rufio
235
303
  # info ディレクトリが存在することを確認
236
304
  return false unless Dir.exist?(info_dir)
237
305
 
306
+ leave_other_modes(:help_directory)
307
+ @pre_help_directory = @directory_listing.current_path
308
+
238
309
  # ヘルプモードを有効化
239
310
  @in_help_mode = true
240
311
 
@@ -273,14 +344,10 @@ module Rufio
273
344
  return false unless @directory_listing
274
345
  return true if @in_log_viewer_mode
275
346
 
276
- exit_help_mode if @in_help_mode
277
-
278
- # 現在のディレクトリを保存
347
+ FileUtils.mkdir_p(@log_dir)
348
+ leave_other_modes(:logs)
279
349
  @pre_log_viewer_directory = @directory_listing.current_path
280
350
 
281
- # log ディレクトリを作成(存在しない場合)
282
- FileUtils.mkdir_p(@log_dir) unless Dir.exist?(@log_dir)
283
-
284
351
  # log ディレクトリに移動
285
352
  navigate_to_directory(@log_dir)
286
353
 
@@ -309,45 +376,19 @@ module Rufio
309
376
 
310
377
  # ヘルプモード時の制限付き親ディレクトリナビゲーション
311
378
  def navigate_parent_with_restriction
312
- if @in_help_mode
313
- # info ディレクトリより上には移動できない
314
- rufio_root = File.expand_path('../..', __dir__)
315
- info_dir = File.join(rufio_root, 'info')
379
+ restricted_root = if @in_help_mode
380
+ File.join(File.expand_path('../..', __dir__), 'info')
381
+ elsif @in_log_viewer_mode
382
+ @log_dir
383
+ end
316
384
 
385
+ if restricted_root
317
386
  current_path = @directory_listing.current_path
318
-
319
- # 現在のパスが info ディレクトリ以下でない場合は移動を許可しない
320
- unless current_path.start_with?(info_dir)
321
- return false
322
- end
323
-
324
- # 現在のパスが info ディレクトリそのものの場合は移動を許可しない
325
- if current_path == info_dir
326
- return false
327
- end
328
-
329
- # info ディレクトリ配下であれば、通常のナビゲーションを実行
330
- navigate_parent
331
- elsif @in_log_viewer_mode
332
- # log ディレクトリより上には移動できない
333
- current_path = @directory_listing.current_path
334
-
335
- # 現在のパスが log ディレクトリ以下でない場合は移動を許可しない
336
- unless current_path.start_with?(@log_dir)
337
- return false
338
- end
339
-
340
- # 現在のパスが log ディレクトリそのものの場合は移動を許可しない
341
- if current_path == @log_dir
342
- return false
343
- end
344
-
345
- # log ディレクトリ配下であれば、通常のナビゲーションを実行
346
- navigate_parent
347
- else
348
- # ヘルプモード・ログビューワモード外では通常のナビゲーション
349
- navigate_parent
387
+ return false unless current_path.start_with?(restricted_root)
388
+ return false if current_path == restricted_root
350
389
  end
390
+
391
+ navigate_parent
351
392
  end
352
393
 
353
394
  # プレビューペイン関連メソッド(NavigationController に委譲)
@@ -402,52 +443,12 @@ module Rufio
402
443
 
403
444
  private
404
445
 
405
- # オーバーレイダイアログを表示してキー入力を待つヘルパーメソッド
406
- # terminal_ui が利用可能な場合はオーバーレイを使用、そうでなければ従来の方法を使用
407
- # @param title [String] ダイアログタイトル
408
- # @param content_lines [Array<String>] コンテンツ行
409
- # @param options [Hash] オプション
410
- # @yield キー入力処理(ブロックが与えられた場合)
411
- # @return [String] 入力されたキー
412
- def show_overlay_dialog(title, content_lines, options = {}, &block)
413
- # terminal_ui が利用可能で、screen と renderer が存在する場合のみオーバーレイを使用
414
- use_overlay = @terminal_ui &&
415
- @terminal_ui.respond_to?(:screen) &&
416
- @terminal_ui.respond_to?(:renderer) &&
417
- @terminal_ui.screen &&
418
- @terminal_ui.renderer
419
-
420
- if use_overlay
421
- # オーバーレイを使用
422
- @terminal_ui.show_overlay_dialog(title, content_lines, options, &block)
423
- else
424
- # フォールバック: 従来の方法
425
- width = options[:width]
426
- height = options[:height]
427
-
428
- unless width && height
429
- width, height = @dialog_renderer.calculate_dimensions(content_lines, {
430
- title: title,
431
- min_width: options[:min_width] || 40,
432
- max_width: options[:max_width] || 80
433
- })
434
- end
435
-
436
- x, y = @dialog_renderer.calculate_center(width, height)
437
-
438
- @dialog_renderer.draw_floating_window(x, y, width, height, title, content_lines, {
439
- border_color: options[:border_color] || "\e[37m",
440
- title_color: options[:title_color] || "\e[1;33m",
441
- content_color: options[:content_color] || "\e[37m"
442
- })
443
-
444
- key = block_given? ? yield : STDIN.getch
445
-
446
- @dialog_renderer.clear_area(x, y, width, height)
447
- @terminal_ui&.refresh_display
448
-
449
- key
450
- end
446
+ # 別モードへ入る際の終了順序はここだけで管理する。
447
+ def leave_other_modes(target)
448
+ exit_help_mode if target != :help_directory && help_mode?
449
+ exit_log_viewer_mode if target != :logs && log_viewer_mode?
450
+ exit_job_mode if target != :jobs && in_job_mode?
451
+ true
451
452
  end
452
453
 
453
454
  def navigate_parent
@@ -495,13 +496,14 @@ module Rufio
495
496
  when :rga_search then rga_search
496
497
  when :add_bookmark then add_bookmark
497
498
  when :bookmark_menu then show_bookmark_menu
498
- when :zoxide then show_zoxide_menu
499
+ when :zoxide then archive_or_zoxide
499
500
  when :start_dir then goto_start_directory
500
501
  when :job_mode then enter_job_mode
501
502
  when :help then show_help_dialog
502
503
  when :log_viewer then enter_log_viewer_mode
503
504
  when :command_mode then activate_command_mode
504
505
  when :quit then exit_request
506
+ when :quit_cd then quit_to_current_directory
505
507
  else
506
508
  false
507
509
  end
@@ -511,6 +513,14 @@ module Rufio
511
513
  @file_op_controller.show_exit_confirmation
512
514
  end
513
515
 
516
+ # 確認なしで終了し、シェル関数(rufio --shell-init)が移動する先として現在のディレクトリを伝える。
517
+ # ヘルプ・ログビューワは rufio 内部のディレクトリを表示しているため対象外。
518
+ def quit_to_current_directory
519
+ return false if @in_help_mode || @in_log_viewer_mode
520
+
521
+ :quit_cd
522
+ end
523
+
514
524
  def fzf_search
515
525
  @search_controller.fzf_search
516
526
  end
@@ -548,8 +558,6 @@ module Rufio
548
558
  true
549
559
  end
550
560
 
551
-
552
-
553
561
  def goto_start_directory
554
562
  @bookmark_controller.goto_start_directory
555
563
  end
@@ -573,17 +581,11 @@ module Rufio
573
581
  @bookmark_controller.show_script_paths_manager
574
582
  end
575
583
 
576
- def navigate_to_directory(path)
577
- return false unless @directory_listing
584
+ # 選択中のアイテムがあれば ZIP 圧縮・展開、なければ zoxide
585
+ def archive_or_zoxide
586
+ return @archive_controller.archive_selected if @selection_manager.any? && !@in_help_mode && !@in_log_viewer_mode
578
587
 
579
- result = @directory_listing.navigate_to_path(path)
580
- if result
581
- @nav_controller.select_index(0)
582
- @nav_controller.clear_filter_mode
583
- true
584
- else
585
- false
586
- end
588
+ show_zoxide_menu
587
589
  end
588
590
 
589
591
  # zoxide 機能
@@ -599,11 +601,9 @@ module Rufio
599
601
 
600
602
  # カーソルを画面下部の入力行に移動
601
603
  def move_to_input_line
602
- # 画面の最終行にカーソルを移動
603
- # terminal_uiから画面の高さを取得できない場合は、24行目(デフォルト)を使用
604
- screen_height = @terminal_ui&.instance_variable_get(:@screen_height) || 24
605
- print "\e[#{screen_height};1H" # 最終行の先頭にカーソル移動
606
- print "\e[2K" # 行全体をクリア
604
+ screen_height = @terminal_ui&.screen_height || 24
605
+ print "\e[#{screen_height};1H"
606
+ print "\e[2K"
607
607
  end
608
608
 
609
609
  # Escキーでキャンセル可能な入力処理
@@ -613,7 +613,7 @@ module Rufio
613
613
  input = []
614
614
 
615
615
  loop do
616
- char = STDIN.getch
616
+ char = KeyInput.getch
617
617
 
618
618
  case char
619
619
  when "\e" # Escape
@@ -657,6 +657,9 @@ module Rufio
657
657
 
658
658
  # ジョブモードに入る
659
659
  def enter_job_mode
660
+ return true if @job_mode.active?
661
+
662
+ leave_other_modes(:jobs)
660
663
  @job_mode.activate
661
664
  @terminal_ui&.set_job_mode(@job_mode, @job_manager, @notification_manager) if @terminal_ui
662
665
  true
@@ -695,6 +698,11 @@ module Rufio
695
698
  end
696
699
 
697
700
  # ジョブがあるかどうか
701
+ # バックグラウンドの圧縮・展開ジョブの進捗・完了を反映する(メインループから呼ぶ)
702
+ def poll_archive_jobs
703
+ @archive_controller.poll
704
+ end
705
+
698
706
  def has_jobs?
699
707
  @job_manager.has_jobs?
700
708
  end
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'script_file'
4
+
3
5
  module Rufio
4
6
  # 閲覧中ディレクトリのスクリプトファイルを検出するスキャナー
5
7
  # カレントディレクトリ直下のスクリプトファイル(.sh, .rb, .py等)を検出し、
6
8
  # コマンドモードから @script.sh 形式で実行可能にする
7
9
  class LocalScriptScanner
8
10
  # サポートするスクリプト拡張子
9
- SUPPORTED_EXTENSIONS = %w[.sh .rb .py .pl .js .ts .ps1].freeze
11
+ SUPPORTED_EXTENSIONS = ScriptFile::EXTENSIONS
10
12
 
11
13
  # @param directory [String, nil] スキャン対象ディレクトリ
12
14
  def initialize(directory = nil)
@@ -33,27 +35,14 @@ module Rufio
33
35
  # @param name [String] スクリプト名(拡張子あり/なし)
34
36
  # @return [Hash, nil] スクリプト情報 { name:, path:, dir: } または nil
35
37
  def find_script(name)
36
- # 完全一致を優先
37
- script = available_scripts.find { |s| s[:name] == name }
38
- return script if script
39
-
40
- # 拡張子なしで検索
41
- SUPPORTED_EXTENSIONS.each do |ext|
42
- script = available_scripts.find { |s| s[:name] == "#{name}#{ext}" }
43
- return script if script
44
- end
45
-
46
- nil
38
+ ScriptFile.find(available_scripts, name)
47
39
  end
48
40
 
49
41
  # Tab補完候補を取得
50
42
  # @param prefix [String] 入力中の文字列
51
43
  # @return [Array<String>] 補完候補のスクリプト名
52
44
  def complete(prefix)
53
- available_scripts
54
- .map { |s| s[:name] }
55
- .select { |name| name.start_with?(prefix) }
56
- .sort
45
+ ScriptFile.complete(available_scripts, prefix)
57
46
  end
58
47
 
59
48
  private
@@ -85,8 +74,7 @@ module Rufio
85
74
  # @param filename [String] ファイル名
86
75
  # @return [Boolean]
87
76
  def script_file?(filename)
88
- ext = File.extname(filename).downcase
89
- SUPPORTED_EXTENSIONS.include?(ext)
77
+ ScriptFile.supported?(filename)
90
78
  end
91
79
  end
92
80
  end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fiddle'
4
+ require_relative 'native_scanner'
5
+ require_relative 'native_scanner_zig'
6
+
7
+ module Rufio
8
+ # Zig 実装のコンテンツ検索(G mode 用)。
9
+ # Ruby 実装(GlobalSearch#scan_content)と同じ結果を、並列走査と SIMD 検索で返す。
10
+ class NativeContentSearch
11
+ HEADER_SIZE = 25
12
+ HEADER_FORMAT = 'CL<Q<L<L<L<'
13
+ STATE_SCANNING = 1
14
+ KINDS = { 1 => :match, 2 => :document, 3 => :error }.freeze
15
+ MAX_QUERY_BYTES = 1024
16
+ POLL_INTERVAL = 0.001
17
+
18
+ # 検索をまたいでファイル内容を保持するキャッシュ(mtime/inode/サイズで毎回検証する)
19
+ class Cache
20
+ DEFAULT_MAX_BYTES = 256 * 1024 * 1024
21
+
22
+ attr_reader :handle
23
+
24
+ def self.finalizer(handle)
25
+ proc { NativeScannerZigFFI.core_cache_destroy(handle) }
26
+ end
27
+
28
+ def initialize(max_bytes: DEFAULT_MAX_BYTES)
29
+ @handle = NativeScannerZigFFI.core_cache_create(max_bytes)
30
+ raise StandardError, 'Failed to create search cache' if @handle.zero?
31
+
32
+ ObjectSpace.define_finalizer(self, self.class.finalizer(@handle))
33
+ end
34
+
35
+ def cached_bytes
36
+ @handle.zero? ? 0 : NativeScannerZigFFI.core_cache_bytes(@handle)
37
+ end
38
+
39
+ # このキャッシュを使う検索をすべて close してから呼ぶこと
40
+ def close
41
+ return if @handle.zero?
42
+
43
+ ObjectSpace.undefine_finalizer(self)
44
+ NativeScannerZigFFI.core_cache_destroy(@handle)
45
+ @handle = 0
46
+ end
47
+ end
48
+
49
+ def self.available?
50
+ NativeScannerZigFFI.search_available?
51
+ end
52
+
53
+ # Zig 側は ASCII のみ大文字小文字を畳み込むため、大文字小文字を持つ非 ASCII 文字は Ruby 実装に任せる
54
+ def self.supports_query?(query)
55
+ return false if query.empty? || query.bytesize > MAX_QUERY_BYTES
56
+
57
+ query.each_char.all? { |char| char.ascii_only? || (char.upcase == char && char.downcase == char) }
58
+ end
59
+
60
+ # マッチ周辺のバイト列から Ruby 実装と同じスニペット(30 文字前から 200 文字)を作る
61
+ def self.snippet(window, offset)
62
+ line = window.dup.force_encoding(Encoding::UTF_8).scrub
63
+ match_index = window.byteslice(0, offset).force_encoding(Encoding::UTF_8).scrub.length
64
+ line[[match_index - 30, 0].max, 200].to_s.strip
65
+ end
66
+
67
+ def initialize(roots:, query:, excluded_extensions: [], document_extensions: [], cache: nil, threads: 0)
68
+ raise StandardError, 'Zig content search is not available' unless self.class.available?
69
+
70
+ @cache = cache
71
+ @path_encoding = Encoding.find('filesystem')
72
+ roots_buf = pack(roots)
73
+ query_buf = query.b
74
+ excluded_buf = pack(excluded_extensions)
75
+ documents_buf = pack(document_extensions)
76
+ @handle = NativeScannerZigFFI.core_search_create(
77
+ roots_buf, roots_buf.bytesize, query_buf, query_buf.bytesize,
78
+ excluded_buf, excluded_buf.bytesize, documents_buf, documents_buf.bytesize,
79
+ threads, cache ? cache.handle : 0
80
+ )
81
+ raise StandardError, 'Failed to start native search' if @handle.zero?
82
+
83
+ @length = Fiddle::Pointer.malloc(8, Fiddle::RUBY_FREE)
84
+ @skipped = 0
85
+ end
86
+
87
+ # [:match, root_index, path, line_number, snippet] / [:document, root_index, path] /
88
+ # [:error, root_index, message] / [:skipped, count] を検索終了まで順に返す
89
+ def each_event(&block)
90
+ return enum_for(:each_event) unless block_given?
91
+
92
+ each_batch { |batch| batch.each(&block) }
93
+ end
94
+
95
+ # 1 回の取り出しごとにイベント配列を返す(UI へまとめて渡すため)
96
+ def each_batch
97
+ return enum_for(:each_batch) unless block_given?
98
+
99
+ loop do
100
+ state = NativeScannerZigFFI.core_search_state(@handle)
101
+ data = drain
102
+ batch = []
103
+ each_record(data) { |event| batch << event }
104
+ skipped = NativeScannerZigFFI.core_search_skipped(@handle)
105
+ if skipped > @skipped
106
+ batch << [:skipped, skipped - @skipped]
107
+ @skipped = skipped
108
+ end
109
+ yield batch unless batch.empty?
110
+ break if data.empty? && state != STATE_SCANNING
111
+
112
+ sleep POLL_INTERVAL if data.empty?
113
+ end
114
+ end
115
+
116
+ def close
117
+ return if @handle.nil? || @handle.zero?
118
+
119
+ NativeScannerZigFFI.core_search_destroy(@handle)
120
+ @handle = 0
121
+ end
122
+
123
+ private
124
+
125
+ def pack(values)
126
+ values.map { |value| value.to_s.b }.join("\0")
127
+ end
128
+
129
+ def drain
130
+ pointer = NativeScannerZigFFI.core_search_drain(@handle, @length)
131
+ length = @length[0, 8].unpack1('Q')
132
+ length.zero? ? ''.b : pointer.to_str(length)
133
+ end
134
+
135
+ def each_record(data)
136
+ position = 0
137
+ while position + HEADER_SIZE <= data.bytesize
138
+ kind, root, line, offset, path_length, extra_length = data.byteslice(position, HEADER_SIZE).unpack(HEADER_FORMAT)
139
+ position += HEADER_SIZE
140
+ path = data.byteslice(position, path_length).force_encoding(@path_encoding)
141
+ position += path_length
142
+ extra = data.byteslice(position, extra_length)
143
+ position += extra_length
144
+
145
+ case KINDS[kind]
146
+ when :match then yield [:match, root, path, line, self.class.snippet(extra, offset)]
147
+ when :document then yield [:document, root, path]
148
+ when :error then yield [:error, root, "#{path}: #{extra.force_encoding(Encoding::UTF_8).scrub}"]
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end