rufio 1.1.0 → 1.4.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/README.md +26 -297
  4. data/README_ja.md +24 -261
  5. data/Rakefile +14 -1
  6. data/bin/rufio +31 -2
  7. data/docs/fast_zip_design_ja.md +167 -0
  8. data/docs/guide.md +410 -0
  9. data/docs/guide_ja.md +410 -0
  10. data/info/help.md +54 -17
  11. data/info/keybindings.md +50 -22
  12. data/info/welcome.md +14 -5
  13. data/lib/rufio/application.rb +12 -1
  14. data/lib/rufio/archive_controller.rb +211 -0
  15. data/lib/rufio/background_command_executor.rb +9 -0
  16. data/lib/rufio/background_status.rb +49 -0
  17. data/lib/rufio/bookmark_controller.rb +38 -121
  18. data/lib/rufio/bookmark_manager.rb +77 -331
  19. data/lib/rufio/command_completion.rb +28 -2
  20. data/lib/rufio/command_history.rb +5 -2
  21. data/lib/rufio/command_mode.rb +60 -134
  22. data/lib/rufio/command_mode_ui.rb +4 -42
  23. data/lib/rufio/config.rb +88 -136
  24. data/lib/rufio/config_loader.rb +4 -11
  25. data/lib/rufio/dialog_renderer.rb +90 -152
  26. data/lib/rufio/dialog_support.rb +69 -0
  27. data/lib/rufio/document_text_extractor.rb +167 -0
  28. data/lib/rufio/file_operation_controller.rb +49 -222
  29. data/lib/rufio/file_operations.rb +28 -6
  30. data/lib/rufio/file_preview.rb +46 -2
  31. data/lib/rufio/global_search.rb +125 -13
  32. data/lib/rufio/help_menu.rb +19 -7
  33. data/lib/rufio/interpreter_resolver.rb +6 -11
  34. data/lib/rufio/job_manager.rb +2 -5
  35. data/lib/rufio/key_input.rb +77 -0
  36. data/lib/rufio/keybind_handler.rb +83 -116
  37. data/lib/rufio/local_script_scanner.rb +6 -18
  38. data/lib/rufio/native/aarch64-linux-gnu/librufio_zig.so +0 -0
  39. data/lib/rufio/native/aarch64-linux-musl/librufio_zig.so +0 -0
  40. data/lib/rufio/native/aarch64-macos/librufio_zig.dylib +0 -0
  41. data/lib/rufio/native/aarch64-windows/rufio_zig.dll +0 -0
  42. data/lib/rufio/native/x86_64-linux-gnu/librufio_zig.so +0 -0
  43. data/lib/rufio/native/x86_64-linux-musl/librufio_zig.so +0 -0
  44. data/lib/rufio/native/x86_64-macos/librufio_zig.dylib +0 -0
  45. data/lib/rufio/native/x86_64-windows/rufio_zig.dll +0 -0
  46. data/lib/rufio/native_content_search.rb +153 -0
  47. data/lib/rufio/native_scanner.rb +13 -68
  48. data/lib/rufio/native_scanner_zig.rb +106 -56
  49. data/lib/rufio/native_zip.rb +121 -0
  50. data/lib/rufio/navigation_controller.rb +20 -26
  51. data/lib/rufio/path_jump.rb +55 -0
  52. data/lib/rufio/screen.rb +2 -12
  53. data/lib/rufio/script_executor.rb +6 -73
  54. data/lib/rufio/script_file.rb +44 -0
  55. data/lib/rufio/script_path_manager.rb +9 -43
  56. data/lib/rufio/script_runner.rb +13 -50
  57. data/lib/rufio/search_controller.rb +1 -1
  58. data/lib/rufio/shell_command_completion.rb +1 -5
  59. data/lib/rufio/shell_init.rb +90 -0
  60. data/lib/rufio/task_status.rb +5 -1
  61. data/lib/rufio/terminal_ui.rb +84 -298
  62. data/lib/rufio/text_utils.rb +26 -3
  63. data/lib/rufio/ui_renderer.rb +73 -45
  64. data/lib/rufio/version.rb +1 -1
  65. data/lib/rufio/zip_archive.rb +314 -0
  66. data/lib/rufio/zip_extractor.rb +346 -0
  67. data/lib/rufio/zip_writer.rb +268 -0
  68. data/lib/rufio/zoxide_integration.rb +5 -43
  69. data/lib/rufio.rb +6 -0
  70. data/lib_zig/rufio_native/Makefile +54 -17
  71. data/lib_zig/rufio_native/bench.sh +36 -0
  72. data/lib_zig/rufio_native/build.zig +32 -35
  73. data/lib_zig/rufio_native/src/cli.zig +99 -0
  74. data/lib_zig/rufio_native/src/main.zig +187 -83
  75. data/lib_zig/rufio_native/src/os.zig +889 -0
  76. data/lib_zig/rufio_native/src/search.zig +919 -0
  77. data/lib_zig/rufio_native/src/search_test.zig +88 -0
  78. data/lib_zig/rufio_native/src/zip.zig +482 -0
  79. metadata +128 -3
  80. 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
@@ -49,9 +54,7 @@ module Rufio
49
54
  return false unless @directory_listing
50
55
 
51
56
  FileUtils.mkdir_p(config_directory)
52
- exit_log_viewer_mode if @in_log_viewer_mode
53
- exit_help_mode if @in_help_mode
54
- exit_job_mode if @job_mode.active?
57
+ leave_other_modes(:config)
55
58
  @nav_controller.unfocus_preview_pane
56
59
  navigate_to_directory(config_directory)
57
60
  rescue SystemCallError => e
@@ -59,6 +62,23 @@ module Rufio
59
62
  false
60
63
  end
61
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
+
62
82
  def preview_scroll_offset
63
83
  @nav_controller.preview_scroll_offset
64
84
  end
@@ -112,6 +132,11 @@ module Rufio
112
132
  @script_path_manager, @notification_manager, @zoxide_integration
113
133
  )
114
134
 
135
+ # ArchiveController(選択アイテムの ZIP 圧縮・展開を担当)
136
+ @archive_controller = ArchiveController.new(
137
+ nil, @dialog_renderer, @selection_manager, @job_manager, @notification_manager
138
+ )
139
+
115
140
  # SearchController(fzf・rga検索を担当)
116
141
  @search_controller = SearchController.new(nil, @file_opener)
117
142
 
@@ -134,12 +159,14 @@ module Rufio
134
159
  @file_op_controller.set_directory_listing(directory_listing)
135
160
  @bookmark_controller.set_directory_listing(directory_listing)
136
161
  @search_controller.set_directory_listing(directory_listing)
162
+ @archive_controller.set_directory_listing(directory_listing)
137
163
  end
138
164
 
139
165
  def set_terminal_ui(terminal_ui)
140
166
  @terminal_ui = terminal_ui
141
167
  @nav_controller.set_terminal_ui(terminal_ui)
142
168
  @file_op_controller.set_terminal_ui(terminal_ui)
169
+ @archive_controller.set_terminal_ui(terminal_ui)
143
170
  @bookmark_controller.set_terminal_ui(terminal_ui)
144
171
  # terminal_ui が設定されたら、bookmark_manager と zoxide_integration にも渡す
145
172
  @bookmark_manager.set_terminal_ui(terminal_ui)
@@ -201,7 +228,7 @@ module Rufio
201
228
 
202
229
  entries = get_active_entries
203
230
 
204
- # Enter キー(特殊処理: プレビューペインフォーカスまたはナビゲーション)
231
+ # Enter キー(ディレクトリ移動)
205
232
  if key == "\r" || key == "\n"
206
233
  return @nav_controller.handle_enter_key(current_entry, @in_help_mode, @in_log_viewer_mode)
207
234
  end
@@ -236,6 +263,10 @@ module Rufio
236
263
  entries[@nav_controller.current_index]
237
264
  end
238
265
 
266
+ def filter_mode?
267
+ @filter_manager.filter_mode
268
+ end
269
+
239
270
  def filter_active?
240
271
  @filter_manager.filter_active?
241
272
  end
@@ -265,11 +296,6 @@ module Rufio
265
296
  return false unless @directory_listing
266
297
  return true if @in_help_mode
267
298
 
268
- exit_log_viewer_mode if @in_log_viewer_mode
269
-
270
- # 現在のディレクトリを保存
271
- @pre_help_directory = @directory_listing.current_path
272
-
273
299
  # info ディレクトリに移動
274
300
  rufio_root = File.expand_path('../..', __dir__)
275
301
  info_dir = File.join(rufio_root, 'info')
@@ -277,6 +303,9 @@ module Rufio
277
303
  # info ディレクトリが存在することを確認
278
304
  return false unless Dir.exist?(info_dir)
279
305
 
306
+ leave_other_modes(:help_directory)
307
+ @pre_help_directory = @directory_listing.current_path
308
+
280
309
  # ヘルプモードを有効化
281
310
  @in_help_mode = true
282
311
 
@@ -315,14 +344,10 @@ module Rufio
315
344
  return false unless @directory_listing
316
345
  return true if @in_log_viewer_mode
317
346
 
318
- exit_help_mode if @in_help_mode
319
-
320
- # 現在のディレクトリを保存
347
+ FileUtils.mkdir_p(@log_dir)
348
+ leave_other_modes(:logs)
321
349
  @pre_log_viewer_directory = @directory_listing.current_path
322
350
 
323
- # log ディレクトリを作成(存在しない場合)
324
- FileUtils.mkdir_p(@log_dir) unless Dir.exist?(@log_dir)
325
-
326
351
  # log ディレクトリに移動
327
352
  navigate_to_directory(@log_dir)
328
353
 
@@ -351,45 +376,19 @@ module Rufio
351
376
 
352
377
  # ヘルプモード時の制限付き親ディレクトリナビゲーション
353
378
  def navigate_parent_with_restriction
354
- if @in_help_mode
355
- # info ディレクトリより上には移動できない
356
- rufio_root = File.expand_path('../..', __dir__)
357
- 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
358
384
 
385
+ if restricted_root
359
386
  current_path = @directory_listing.current_path
360
-
361
- # 現在のパスが info ディレクトリ以下でない場合は移動を許可しない
362
- unless current_path.start_with?(info_dir)
363
- return false
364
- end
365
-
366
- # 現在のパスが info ディレクトリそのものの場合は移動を許可しない
367
- if current_path == info_dir
368
- return false
369
- end
370
-
371
- # info ディレクトリ配下であれば、通常のナビゲーションを実行
372
- navigate_parent
373
- elsif @in_log_viewer_mode
374
- # log ディレクトリより上には移動できない
375
- current_path = @directory_listing.current_path
376
-
377
- # 現在のパスが log ディレクトリ以下でない場合は移動を許可しない
378
- unless current_path.start_with?(@log_dir)
379
- return false
380
- end
381
-
382
- # 現在のパスが log ディレクトリそのものの場合は移動を許可しない
383
- if current_path == @log_dir
384
- return false
385
- end
386
-
387
- # log ディレクトリ配下であれば、通常のナビゲーションを実行
388
- navigate_parent
389
- else
390
- # ヘルプモード・ログビューワモード外では通常のナビゲーション
391
- navigate_parent
387
+ return false unless current_path.start_with?(restricted_root)
388
+ return false if current_path == restricted_root
392
389
  end
390
+
391
+ navigate_parent
393
392
  end
394
393
 
395
394
  # プレビューペイン関連メソッド(NavigationController に委譲)
@@ -444,52 +443,12 @@ module Rufio
444
443
 
445
444
  private
446
445
 
447
- # オーバーレイダイアログを表示してキー入力を待つヘルパーメソッド
448
- # terminal_ui が利用可能な場合はオーバーレイを使用、そうでなければ従来の方法を使用
449
- # @param title [String] ダイアログタイトル
450
- # @param content_lines [Array<String>] コンテンツ行
451
- # @param options [Hash] オプション
452
- # @yield キー入力処理(ブロックが与えられた場合)
453
- # @return [String] 入力されたキー
454
- def show_overlay_dialog(title, content_lines, options = {}, &block)
455
- # terminal_ui が利用可能で、screen と renderer が存在する場合のみオーバーレイを使用
456
- use_overlay = @terminal_ui &&
457
- @terminal_ui.respond_to?(:screen) &&
458
- @terminal_ui.respond_to?(:renderer) &&
459
- @terminal_ui.screen &&
460
- @terminal_ui.renderer
461
-
462
- if use_overlay
463
- # オーバーレイを使用
464
- @terminal_ui.show_overlay_dialog(title, content_lines, options, &block)
465
- else
466
- # フォールバック: 従来の方法
467
- width = options[:width]
468
- height = options[:height]
469
-
470
- unless width && height
471
- width, height = @dialog_renderer.calculate_dimensions(content_lines, {
472
- title: title,
473
- min_width: options[:min_width] || 40,
474
- max_width: options[:max_width] || 80
475
- })
476
- end
477
-
478
- x, y = @dialog_renderer.calculate_center(width, height)
479
-
480
- @dialog_renderer.draw_floating_window(x, y, width, height, title, content_lines, {
481
- border_color: options[:border_color] || "\e[37m",
482
- title_color: options[:title_color] || "\e[1;33m",
483
- content_color: options[:content_color] || "\e[37m"
484
- })
485
-
486
- key = block_given? ? yield : STDIN.getch
487
-
488
- @dialog_renderer.clear_area(x, y, width, height)
489
- @terminal_ui&.refresh_display
490
-
491
- key
492
- 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
493
452
  end
494
453
 
495
454
  def navigate_parent
@@ -517,6 +476,7 @@ module Rufio
517
476
  when :top then @nav_controller.move_to_top
518
477
  when :bottom then @nav_controller.move_to_bottom(entries)
519
478
  when :refresh then @nav_controller.refresh
479
+ when :preview then focus_preview_pane
520
480
  when :open_file then @nav_controller.open_current_file(current_entry)
521
481
  when :open_explorer then @nav_controller.open_directory_in_explorer
522
482
  when :rename then @file_op_controller.rename_current_file(current_entry)
@@ -537,13 +497,14 @@ module Rufio
537
497
  when :rga_search then rga_search
538
498
  when :add_bookmark then add_bookmark
539
499
  when :bookmark_menu then show_bookmark_menu
540
- when :zoxide then show_zoxide_menu
500
+ when :zoxide then archive_or_zoxide
541
501
  when :start_dir then goto_start_directory
542
502
  when :job_mode then enter_job_mode
543
503
  when :help then show_help_dialog
544
504
  when :log_viewer then enter_log_viewer_mode
545
505
  when :command_mode then activate_command_mode
546
506
  when :quit then exit_request
507
+ when :quit_cd then quit_to_current_directory
547
508
  else
548
509
  false
549
510
  end
@@ -553,6 +514,14 @@ module Rufio
553
514
  @file_op_controller.show_exit_confirmation
554
515
  end
555
516
 
517
+ # 確認なしで終了し、シェル関数(rufio --shell-init)が移動する先として現在のディレクトリを伝える。
518
+ # ヘルプ・ログビューワは rufio 内部のディレクトリを表示しているため対象外。
519
+ def quit_to_current_directory
520
+ return false if @in_help_mode || @in_log_viewer_mode
521
+
522
+ :quit_cd
523
+ end
524
+
556
525
  def fzf_search
557
526
  @search_controller.fzf_search
558
527
  end
@@ -590,8 +559,6 @@ module Rufio
590
559
  true
591
560
  end
592
561
 
593
-
594
-
595
562
  def goto_start_directory
596
563
  @bookmark_controller.goto_start_directory
597
564
  end
@@ -615,17 +582,11 @@ module Rufio
615
582
  @bookmark_controller.show_script_paths_manager
616
583
  end
617
584
 
618
- def navigate_to_directory(path)
619
- return false unless @directory_listing
585
+ # 選択中のアイテムがあれば ZIP 圧縮・展開、なければ zoxide
586
+ def archive_or_zoxide
587
+ return @archive_controller.archive_selected if @selection_manager.any? && !@in_help_mode && !@in_log_viewer_mode
620
588
 
621
- result = @directory_listing.navigate_to_path(path)
622
- if result
623
- @nav_controller.select_index(0)
624
- @nav_controller.clear_filter_mode
625
- true
626
- else
627
- false
628
- end
589
+ show_zoxide_menu
629
590
  end
630
591
 
631
592
  # zoxide 機能
@@ -641,11 +602,9 @@ module Rufio
641
602
 
642
603
  # カーソルを画面下部の入力行に移動
643
604
  def move_to_input_line
644
- # 画面の最終行にカーソルを移動
645
- # terminal_uiから画面の高さを取得できない場合は、24行目(デフォルト)を使用
646
- screen_height = @terminal_ui&.instance_variable_get(:@screen_height) || 24
647
- print "\e[#{screen_height};1H" # 最終行の先頭にカーソル移動
648
- print "\e[2K" # 行全体をクリア
605
+ screen_height = @terminal_ui&.screen_height || 24
606
+ print "\e[#{screen_height};1H"
607
+ print "\e[2K"
649
608
  end
650
609
 
651
610
  # Escキーでキャンセル可能な入力処理
@@ -655,7 +614,7 @@ module Rufio
655
614
  input = []
656
615
 
657
616
  loop do
658
- char = STDIN.getch
617
+ char = KeyInput.getch
659
618
 
660
619
  case char
661
620
  when "\e" # Escape
@@ -699,6 +658,9 @@ module Rufio
699
658
 
700
659
  # ジョブモードに入る
701
660
  def enter_job_mode
661
+ return true if @job_mode.active?
662
+
663
+ leave_other_modes(:jobs)
702
664
  @job_mode.activate
703
665
  @terminal_ui&.set_job_mode(@job_mode, @job_manager, @notification_manager) if @terminal_ui
704
666
  true
@@ -737,6 +699,11 @@ module Rufio
737
699
  end
738
700
 
739
701
  # ジョブがあるかどうか
702
+ # バックグラウンドの圧縮・展開ジョブの進捗・完了を反映する(メインループから呼ぶ)
703
+ def poll_archive_jobs
704
+ @archive_controller.poll
705
+ end
706
+
740
707
  def has_jobs?
741
708
  @job_manager.has_jobs?
742
709
  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