rufio 1.4.1 → 1.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fe4f7b2aae6225c14b25136916d32f34a8090c3e01a356f70aadfac88a3525bf
4
- data.tar.gz: 0f529031131f0b2cfe7fb713068bf079977a7793c5f676a9b222e083014789e7
3
+ metadata.gz: e68729d1cfbfe899d2bdb0c2626ab46958561319aa6a33f8224eb5aaae4e0ad7
4
+ data.tar.gz: 93bfc8f597d0c16339a168b1711abde8bc5251fb7cd8209b199c3d71f0e7068d
5
5
  SHA512:
6
- metadata.gz: 504988c5df9ae95601119734024a47d329a55abc4dc4a23cb815dfe90acb8d81836f6fb5a874d114af79d6375e755b9282e789088a2b14e1a276c72c8cfcc45c
7
- data.tar.gz: 7d16fde591163866e3ed5560711f7c6f9f5c7918032a234b5dada4e253be11b98f5af1177b487aac6126946bb8c95de80813808df7a6df5a9d073ab71c4e27de
6
+ metadata.gz: c5631c3ef32be2d26bd9759d32a3c7680274b49291e14054bce8184821445ce9f3209a1c99ceeffcfcde0149d458215c2d3328ce363cff8c85f6b470b5ade7f5
7
+ data.tar.gz: 60dfadd02062abd388eb458083685f7b36fb98b0193b5a95a94bccdb6f7a8b962e59f46cbccef12e8962ad0e35688ef522d1dbf6a89bd44c6049717d93f2e602
data/CHANGELOG.md CHANGED
@@ -7,8 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.5.0] - 2026-11-14
11
+
10
12
  ### Added
11
13
  - G mode supports `Ctrl+J` / `Ctrl+K` to select the next / previous result; Enter (CR) opens the selected result
14
+ - `GLOBAL_SEARCH_EXCLUDED_EXTENSIONS` accepts names starting with `/` (for example `/.git`, `/node_modules`, `/.DS_Store`) to skip a directory and everything under it, or files with exactly that name. Names ignore case, and extension entries such as `.png` keep working as before
15
+ - The bundled Zig library adds a file listing mode (`core_files_create`) that walks directories in parallel without opening files, rebuilt for all eight bundled platforms. The `rufio_search` benchmark CLI gains `--files` for comparison with `fd`
16
+
17
+ ### Changed
18
+ - G mode file name search walks bookmarked directories with the parallel Zig engine instead of a single Ruby thread. `RUFIO_NATIVE_SEARCH=0` or an older library without the file listing function falls back to the Ruby walk
12
19
 
13
20
  ## [1.4.1] - 2026-11-08
14
21
 
data/docs/guide.md CHANGED
@@ -140,15 +140,15 @@ Content search also reads documents, without Python, Microsoft Office or convers
140
140
 
141
141
  Extracted text is cached in memory (up to 64 documents, about 16 MiB) and re-extracted when a file changes. Scanned images need OCR, which is not included. Legacy `.xls` / `.ppt` and slide notes are not supported.
142
142
 
143
- ### Excluding Extensions
143
+ ### Excluding Extensions and Directories
144
144
 
145
145
  Add this to `~/.config/rufio/config.rb` and restart rufio. It applies to both file names and contents:
146
146
 
147
147
  ```ruby
148
- GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.png .jpg .zip .tar.gz .log].freeze
148
+ GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.png .jpg .zip .tar.gz .log /.git /node_modules /.DS_Store].freeze
149
149
  ```
150
150
 
151
- Extensions ignore case, the leading dot is optional, and the default `[]` excludes nothing.
151
+ Extensions ignore case, the leading dot is optional, and the default `[]` excludes nothing. An entry starting with `/` is a name: a directory with that name is skipped with everything under it, and a file with exactly that name is skipped.
152
152
 
153
153
  ## Bookmarks and History
154
154
 
@@ -323,8 +323,8 @@ APPLICATIONS = {
323
323
  :default => 'open'
324
324
  }.freeze
325
325
 
326
- # Extensions excluded from G mode search
327
- GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.log].freeze
326
+ # Extensions (and /names of directories or files) excluded from G mode search
327
+ GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.log /node_modules].freeze
328
328
  ```
329
329
 
330
330
  Action names for `KEYBINDS`:
data/docs/guide_ja.md CHANGED
@@ -140,15 +140,15 @@ Python・Microsoft Office・変換ツールなしで文書の中も検索でき
140
140
 
141
141
  抽出した文字列はメモリにキャッシュし(最大 64 文書・約 16 MiB)、ファイルが変わると再抽出します。画像だけの PDF には OCR が必要で、これは含みません。旧形式の `.xls` / `.ppt` とスライドノートは対象外です。
142
142
 
143
- ### 拡張子の除外
143
+ ### 拡張子・ディレクトリの除外
144
144
 
145
145
  `~/.config/rufio/config.rb` に次を追加して rufio を再起動します。ファイル名・内容の両方に適用されます。
146
146
 
147
147
  ```ruby
148
- GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.png .jpg .zip .tar.gz .log].freeze
148
+ GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.png .jpg .zip .tar.gz .log /.git /node_modules /.DS_Store].freeze
149
149
  ```
150
150
 
151
- 大文字・小文字は区別せず、先頭のドットは省略できます。デフォルトの `[]` は何も除外しません。
151
+ 大文字・小文字は区別せず、先頭のドットは省略できます。デフォルトの `[]` は何も除外しません。`/` で始まる要素は名前の指定になり、その名前のディレクトリは配下ごと、名前が完全に一致するファイルは単体で除外します。
152
152
 
153
153
  ## ブックマークと履歴
154
154
 
@@ -323,8 +323,8 @@ APPLICATIONS = {
323
323
  :default => 'open'
324
324
  }.freeze
325
325
 
326
- # G モードの検索から除外する拡張子
327
- GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.log].freeze
326
+ # G モードの検索から除外する拡張子(/ 始まりはディレクトリ・ファイル名)
327
+ GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.log /node_modules].freeze
328
328
  ```
329
329
 
330
330
  `KEYBINDS` で使えるアクション名:
data/info/help.md CHANGED
@@ -149,7 +149,7 @@ Search file names or text inside every bookmarked directory.
149
149
  - Content search also reads `.pdf` (page), `.xlsx` (sheet/cell) and `.pptx` (slide)
150
150
  - `Up/Down` or `Ctrl+J/K or Ctrl+N/P`: Select a result | `Enter`: Go to the file in Files mode
151
151
  - `Ctrl+U`: Clear the query | `ESC`: Return to the previous mode
152
- - Exclude extensions with `GLOBAL_SEARCH_EXCLUDED_EXTENSIONS` in `~/.config/rufio/config.rb`
152
+ - Exclude extensions (or `/name` directories and files) with `GLOBAL_SEARCH_EXCLUDED_EXTENSIONS` in `~/.config/rufio/config.rb`
153
153
 
154
154
  ### Config Mode (`C`)
155
155
 
@@ -110,9 +110,9 @@ module Rufio
110
110
 
111
111
  values.select { |value| value.is_a?(String) }.map do |value|
112
112
  extension = value.strip.downcase
113
- next if extension.empty? || extension == '.'
113
+ next if extension.empty? || extension == '.' || extension == '/'
114
114
 
115
- extension.start_with?('.') ? extension : ".#{extension}"
115
+ extension.start_with?('.', '/') ? extension : ".#{extension}"
116
116
  end.compact.uniq
117
117
  end
118
118
 
@@ -83,7 +83,7 @@ module Rufio
83
83
  when :entries
84
84
  # Zig 実装の結果はまとめて届くので、1 フレーム 256 件の上限に縛られない
85
85
  @entries.concat(value)
86
- @results.concat(value)
86
+ @results.concat(@search_type == :content || @query.empty? ? value : value.select { |entry| matches_query?(entry) })
87
87
  when :error then @errors << value
88
88
  when :skipped then @skipped_files += value || 1
89
89
  when :done then @scanning = false
@@ -190,11 +190,14 @@ module Rufio
190
190
  return unless @scanning
191
191
 
192
192
  roots, events, type, query = @roots, @events, @search_type, @query.downcase
193
- native_cache = self.class.native_search? ? (@native_cache ||= NativeContentSearch::Cache.new) : nil
193
+ native = self.class.native_search?
194
+ native_cache = native && type == :content ? (@native_cache ||= NativeContentSearch::Cache.new) : nil
194
195
  @worker = Thread.new do
195
196
  begin
196
- if native_cache && type == :content && NativeContentSearch.supports_query?(query)
197
+ if native && type == :content && NativeContentSearch.supports_query?(query)
197
198
  scan_roots_natively(roots, events, query, native_cache)
199
+ elsif native && type == :filename && NativeContentSearch.files_available?
200
+ scan_files_natively(roots, events)
198
201
  else
199
202
  scan_roots(roots, events, type, query)
200
203
  end
@@ -223,6 +226,8 @@ module Rufio
223
226
 
224
227
  def scan_roots(roots, events, type, query)
225
228
  visited_dirs = {}
229
+ excluded_names = @excluded_extensions.select { |e| e.start_with?('/') }.map { |e| e.delete_prefix('/') }
230
+ excluded_suffixes = @excluded_extensions.reject { |e| e.start_with?('/') }
226
231
  roots.each do |name, root|
227
232
  begin
228
233
  root = File.expand_path(root)
@@ -238,11 +243,12 @@ module Rufio
238
243
  begin
239
244
  Dir.each_child(directory) do |child|
240
245
  path = File.join(directory, child)
246
+ folded_child = child.scrub.downcase
241
247
  # ディレクトリシンボリックリンクは追わず、循環・範囲外の走査を防ぐ。
242
248
  if File.directory?(path)
243
- pending << path unless File.symlink?(path)
249
+ pending << path unless File.symlink?(path) || excluded_names.include?(folded_child)
244
250
  elsif File.file?(path)
245
- if child.scrub.downcase.end_with?(*@excluded_extensions)
251
+ if excluded_names.include?(folded_child) || (!excluded_suffixes.empty? && folded_child.end_with?(*excluded_suffixes))
246
252
  events << [:skipped, nil]
247
253
  next
248
254
  end
@@ -265,6 +271,39 @@ module Rufio
265
271
  end
266
272
  end
267
273
 
274
+ # Zig 実装でブックマーク配下のファイル一覧を並列走査する
275
+ def scan_files_natively(roots, events)
276
+ valid_roots = []
277
+ roots.each do |name, root|
278
+ root = File.expand_path(root)
279
+ raise Errno::ENOTDIR, root unless File.directory?(root)
280
+
281
+ valid_roots << [name, root]
282
+ rescue SystemCallError, IOError => e
283
+ events << [:error, "#{name}: #{e.message}"]
284
+ end
285
+ return if valid_roots.empty?
286
+
287
+ engine = NativeContentSearch.new(roots: valid_roots.map(&:last), query: nil,
288
+ excluded_extensions: @excluded_extensions)
289
+ engine.each_batch do |batch|
290
+ entries = []
291
+ batch.each do |kind, *args|
292
+ case kind
293
+ when :file, :match
294
+ name, root = valid_roots[args[0]]
295
+ path = args[1]
296
+ entries << { path: path, bookmark: name, relative_path: path.delete_prefix(root + File::SEPARATOR) }
297
+ when :error then events << [:error, args[1]]
298
+ when :skipped then events << [:skipped, args[0]]
299
+ end
300
+ end
301
+ events << [:entries, entries] unless entries.empty?
302
+ end
303
+ ensure
304
+ engine&.close
305
+ end
306
+
268
307
  # Zig 実装で走査・検索し、PDF などのドキュメントだけ Ruby 側で抽出する
269
308
  def scan_roots_natively(roots, events, query, cache)
270
309
  valid_roots = []
@@ -100,9 +100,10 @@ module Rufio
100
100
  'Yellow Searching... + spinner: Search is running.',
101
101
  'Green Search complete: All bookmarked directories scanned.',
102
102
  'Exclude extensions in ~/.config/rufio/config.rb:',
103
- 'GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.zip .log]',
103
+ 'GLOBAL_SEARCH_EXCLUDED_EXTENSIONS = %w[.zip .log /node_modules]',
104
104
  'Applies to names and contents. Restart Rufio after editing.',
105
105
  'Extensions ignore case; leading dots are optional; [] excludes none.',
106
+ '/name skips a directory with that name, or a file with exactly that name.',
106
107
  'Type to filter | Up/Down or Ctrl+J/K or Ctrl+N/P: Select',
107
108
  'Enter: Go to the selected file in Files mode',
108
109
  'ESC: Return to the previous mode | Ctrl+U: Clear query',
@@ -50,6 +50,10 @@ module Rufio
50
50
  NativeScannerZigFFI.search_available?
51
51
  end
52
52
 
53
+ def self.files_available?
54
+ NativeScannerZigFFI.files_available?
55
+ end
56
+
53
57
  # Zig 側は ASCII のみ大文字小文字を畳み込むため、大文字小文字を持つ非 ASCII 文字は Ruby 実装に任せる
54
58
  def self.supports_query?(query)
55
59
  return false if query.empty? || query.bytesize > MAX_QUERY_BYTES
@@ -64,20 +68,33 @@ module Rufio
64
68
  line[[match_index - 30, 0].max, 200].to_s.strip
65
69
  end
66
70
 
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?
71
+ def initialize(roots:, query: nil, excluded_extensions: [], document_extensions: [], cache: nil, threads: 0)
72
+ @files_only = query.nil?
73
+ if @files_only
74
+ raise StandardError, 'Zig file search is not available' unless self.class.files_available?
75
+ else
76
+ raise StandardError, 'Zig content search is not available' unless self.class.available?
77
+ end
69
78
 
70
79
  @cache = cache
71
80
  @path_encoding = Encoding.find('filesystem')
72
81
  roots_buf = pack(roots)
73
- query_buf = query.b
74
82
  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
- )
83
+ @handle = if @files_only
84
+ NativeScannerZigFFI.core_files_create(
85
+ roots_buf, roots_buf.bytesize,
86
+ excluded_buf, excluded_buf.bytesize,
87
+ threads
88
+ )
89
+ else
90
+ query_buf = query.b
91
+ documents_buf = pack(document_extensions)
92
+ NativeScannerZigFFI.core_search_create(
93
+ roots_buf, roots_buf.bytesize, query_buf, query_buf.bytesize,
94
+ excluded_buf, excluded_buf.bytesize, documents_buf, documents_buf.bytesize,
95
+ threads, cache ? cache.handle : 0
96
+ )
97
+ end
81
98
  raise StandardError, 'Failed to start native search' if @handle.zero?
82
99
 
83
100
  @length = Fiddle::Pointer.malloc(8, Fiddle::RUBY_FREE)
@@ -143,7 +160,12 @@ module Rufio
143
160
  position += extra_length
144
161
 
145
162
  case KINDS[kind]
146
- when :match then yield [:match, root, path, line, self.class.snippet(extra, offset)]
163
+ when :match
164
+ if @files_only
165
+ yield [:file, root, path]
166
+ else
167
+ yield [:match, root, path, line, self.class.snippet(extra, offset)]
168
+ end
147
169
  when :document then yield [:document, root, path]
148
170
  when :error then yield [:error, root, "#{path}: #{extra.force_encoding(Encoding::UTF_8).scrub}"]
149
171
  end
@@ -101,6 +101,12 @@ module Rufio
101
101
  @search_available == true
102
102
  end
103
103
 
104
+ # ファイル一覧走査 API(古いライブラリには存在しない)
105
+ def files_available?
106
+ load! unless @loaded
107
+ @files_available == true
108
+ end
109
+
104
110
  # ZIP データ処理 API(古いライブラリには存在しない)
105
111
  def zip_available?
106
112
  load! unless @loaded
@@ -125,9 +131,16 @@ module Rufio
125
131
  extern 'uint64_t core_cache_bytes(uint64_t)'
126
132
  extern 'void core_cache_destroy(uint64_t)'
127
133
  @search_available = true
134
+ begin
135
+ extern 'uint64_t core_files_create(const char*, size_t, const char*, size_t, uint32_t)'
136
+ @files_available = true
137
+ rescue Fiddle::DLError
138
+ @files_available = false
139
+ end
128
140
  rescue Fiddle::DLError => e
129
141
  warn "Zig content search unavailable: #{e.message}" if ENV['RUFIO_DEBUG']
130
142
  @search_available = false
143
+ @files_available = false
131
144
  end
132
145
 
133
146
  def load_zip_functions!
data/lib/rufio/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rufio
4
- VERSION = '1.4.1'
4
+ VERSION = '1.5.0'
5
5
  end
@@ -1,7 +1,9 @@
1
- //! ripgrep と同条件で比較するためのベンチマーク用 CLI。
2
- //! 使い方: rufio_search [-j N] [-r N] QUERY DIR...
1
+ //! ripgrep / fd と同条件で比較するためのベンチマーク用 CLI。
2
+ //! 使い方:
3
+ //! rufio_search [-j N] [-r N] QUERY DIR...
4
+ //! rufio_search --files [-j N] [DIR...]
3
5
  //! -r N: キャッシュを使って同じ検索を N 回繰り返し、各回の所要時間を標準エラーに出す(G mode の再検索を再現)
4
- //! 出力は rg -n と同じ `path:line:text` 形式(text はマッチ周辺の最大 1KB)。
6
+ //! 出力は rg -n と同じ `path:line:text` 形式(--files 時は `path` のみ)。
5
7
  const std = @import("std");
6
8
  const c = std.c;
7
9
  const search = @import("search.zig");
@@ -17,6 +19,7 @@ pub fn main(init: std.process.Init.Minimal) !void {
17
19
  _ = args.skip();
18
20
  var threads: usize = 0;
19
21
  var repeat: usize = 0;
22
+ var files_only = false;
20
23
  var query: ?[]const u8 = null;
21
24
  var roots: std.ArrayList(u8) = .empty;
22
25
  const alloc = std.heap.c_allocator;
@@ -27,18 +30,24 @@ pub fn main(init: std.process.Init.Minimal) !void {
27
30
  threads = try std.fmt.parseInt(usize, args.next() orelse return error.MissingValue, 10);
28
31
  } else if (std.mem.eql(u8, arg, "-r")) {
29
32
  repeat = try std.fmt.parseInt(usize, args.next() orelse return error.MissingValue, 10);
30
- } else if (query == null) {
33
+ } else if (std.mem.eql(u8, arg, "--files")) {
34
+ files_only = true;
35
+ } else if (!files_only and query == null) {
31
36
  query = arg;
32
37
  } else {
33
38
  if (roots.items.len > 0) try roots.append(alloc, 0);
34
39
  try roots.appendSlice(alloc, std.mem.trimEnd(u8, arg, "/"));
35
40
  }
36
41
  }
42
+ if (roots.items.len == 0) try roots.append(alloc, '.');
43
+ if (files_only) {
44
+ _ = try runSearch(roots.items, null, threads, null, true);
45
+ return;
46
+ }
37
47
  const q = query orelse {
38
- writeAll("usage: rufio_search [-j N] [-r N] QUERY DIR...\n");
48
+ writeAll("usage: rufio_search [--files] [-j N] [-r N] [QUERY] DIR...\n");
39
49
  return;
40
50
  };
41
- if (roots.items.len == 0) try roots.append(alloc, '.');
42
51
 
43
52
  if (repeat > 0) {
44
53
  const cache = try search.Cache.create(256 * 1024 * 1024);
@@ -56,9 +65,12 @@ pub fn main(init: std.process.Init.Minimal) !void {
56
65
  _ = try runSearch(roots.items, q, threads, null, true);
57
66
  }
58
67
 
59
- fn runSearch(roots: []const u8, q: []const u8, threads: usize, cache: ?*search.Cache, print: bool) !usize {
68
+ fn runSearch(roots: []const u8, q: ?[]const u8, threads: usize, cache: ?*search.Cache, print: bool) !usize {
60
69
  const alloc = std.heap.c_allocator;
61
- const s = try search.Search.create(roots, q, "", "", threads, cache);
70
+ const s = if (q) |query_str|
71
+ try search.Search.create(roots, query_str, "", "", threads, cache)
72
+ else
73
+ try search.Search.createFiles(roots, "/.git\x00/node_modules\x00/.ds_store", threads);
62
74
  defer s.destroy();
63
75
 
64
76
  var lines: usize = 0;
@@ -81,8 +93,10 @@ fn runSearch(roots: []const u8, q: []const u8, threads: usize, cache: ?*search.C
81
93
  lines += 1;
82
94
  if (!print) continue;
83
95
  try out.appendSlice(alloc, a);
84
- try out.appendSlice(alloc, try std.fmt.bufPrint(&line_buf, ":{d}:", .{line}));
85
- try out.appendSlice(alloc, std.mem.trimEnd(u8, b, "\r\n"));
96
+ if (q != null) {
97
+ try out.appendSlice(alloc, try std.fmt.bufPrint(&line_buf, ":{d}:", .{line}));
98
+ try out.appendSlice(alloc, std.mem.trimEnd(u8, b, "\r\n"));
99
+ }
86
100
  try out.append(alloc, '\n');
87
101
  }
88
102
  if (out.items.len >= 1 << 16) {
@@ -373,6 +373,22 @@ export fn core_search_create(
373
373
  return @intFromPtr(s);
374
374
  }
375
375
 
376
+ /// ファイル一覧走査(fd 互換)を開始してハンドルを返す(失敗時は 0)。threads=0 で CPU 数
377
+ export fn core_files_create(
378
+ roots: [*]const u8,
379
+ roots_len: usize,
380
+ excluded: [*]const u8,
381
+ excluded_len: usize,
382
+ threads: u32,
383
+ ) u64 {
384
+ const s = search.Search.createFiles(
385
+ roots[0..roots_len],
386
+ excluded[0..excluded_len],
387
+ threads,
388
+ ) catch return 0;
389
+ return @intFromPtr(s);
390
+ }
391
+
376
392
  export fn core_search_state(handle: u64) u8 {
377
393
  if (handle == 0) return @intFromEnum(search.State.failed);
378
394
  const s: *search.Search = @ptrFromInt(handle);
@@ -282,6 +282,7 @@ const Job = struct {
282
282
 
283
283
  pub const Search = struct {
284
284
  matcher: Matcher,
285
+ files_only: bool = false,
285
286
  roots: StringList,
286
287
  excluded: StringList,
287
288
  documents: StringList,
@@ -313,10 +314,21 @@ pub const Search = struct {
313
314
 
314
315
  pub fn create(roots: []const u8, query: []const u8, excluded: []const u8, documents: []const u8, threads: usize, cache: ?*Cache) !*Search {
315
316
  if (query.len == 0 or query.len > MAX_NEEDLE or roots.len == 0) return error.InvalidArgument;
317
+ return createInternal(roots, query, excluded, documents, threads, cache, false);
318
+ }
319
+
320
+ /// ファイル一覧列挙モード(fd 互換・ファイル内容は開かずに高速走査)
321
+ pub fn createFiles(roots: []const u8, excluded: []const u8, threads: usize) !*Search {
322
+ if (roots.len == 0) return error.InvalidArgument;
323
+ return createInternal(roots, "", excluded, "", threads, null, true);
324
+ }
325
+
326
+ fn createInternal(roots: []const u8, query: []const u8, excluded: []const u8, documents: []const u8, threads: usize, cache: ?*Cache, files_only: bool) !*Search {
316
327
  const self = try std.heap.c_allocator.create(Search);
317
328
  errdefer std.heap.c_allocator.destroy(self);
318
329
  self.* = .{
319
330
  .matcher = Matcher.init(query),
331
+ .files_only = files_only,
320
332
  .roots = try StringList.init(roots),
321
333
  .excluded = try StringList.init(excluded),
322
334
  .documents = try StringList.init(documents),
@@ -499,7 +511,9 @@ const Worker = struct {
499
511
  key: Buf = .{},
500
512
 
501
513
  fn init(self: *Worker) !void {
502
- try self.read.reserve(READ_BUFFER);
514
+ if (!self.s.files_only) {
515
+ try self.read.reserve(READ_BUFFER);
516
+ }
503
517
  try self.scratch.init();
504
518
  try self.out.reserve(LOCAL_FLUSH * 2);
505
519
  }
@@ -520,7 +534,13 @@ const Worker = struct {
520
534
  }
521
535
 
522
536
  fn record(self: *Worker, kind: Kind, root: u32, line: u64, offset: u32, dir: []const u8, name: []const u8, extra: []const u8) void {
523
- const path_len = if (name.len == 0) dir.len else dir.len + 1 + name.len;
537
+ const clean_dir = if (name.len > 0 and std.mem.eql(u8, dir, "."))
538
+ ""
539
+ else if (name.len > 0 and std.mem.startsWith(u8, dir, "./"))
540
+ dir[2..]
541
+ else
542
+ dir;
543
+ const path_len = if (name.len == 0) clean_dir.len else if (clean_dir.len == 0) name.len else clean_dir.len + 1 + name.len;
524
544
  self.out.reserve(self.out.len + RECORD_HEADER + path_len + extra.len) catch return;
525
545
  var hdr: [RECORD_HEADER]u8 = undefined;
526
546
  hdr[0] = @intFromEnum(kind);
@@ -530,9 +550,13 @@ const Worker = struct {
530
550
  std.mem.writeInt(u32, hdr[17..21], @intCast(path_len), .little);
531
551
  std.mem.writeInt(u32, hdr[21..25], @intCast(extra.len), .little);
532
552
  self.out.append(&hdr) catch unreachable;
533
- self.out.append(dir) catch unreachable;
553
+ if (clean_dir.len > 0) {
554
+ self.out.append(clean_dir) catch unreachable;
555
+ if (name.len > 0) {
556
+ self.out.append("/") catch unreachable;
557
+ }
558
+ }
534
559
  if (name.len > 0) {
535
- self.out.append("/") catch unreachable;
536
560
  self.out.append(name) catch unreachable;
537
561
  }
538
562
  self.out.append(extra) catch unreachable;
@@ -542,7 +566,17 @@ const Worker = struct {
542
566
  self.record(.err, root, 0, 0, dir, name, os.lastErrorMessage());
543
567
  }
544
568
 
569
+ fn isIgnoredDir(self: *Worker, name: []const u8) bool {
570
+ for (self.s.excluded.items) |ext| {
571
+ if (ext.len > 1 and ext[0] == '/' and name.len == ext.len - 1 and hasSuffixIgnoreCase(name, ext[1..])) {
572
+ return true;
573
+ }
574
+ }
575
+ return false;
576
+ }
577
+
545
578
  fn pushSubdir(self: *Worker, job: *Job, name: []const u8) void {
579
+ if (self.isIgnoredDir(name)) return;
546
580
  const sub = Job.create(job.root, job.path(), name) orelse return;
547
581
  sub.next = self.subdirs;
548
582
  if (self.subdirs == null) self.subdirs_tail = sub;
@@ -586,11 +620,24 @@ const Worker = struct {
586
620
 
587
621
  fn handleFile(self: *Worker, dir: *os.Dir, job: *Job, name: [:0]const u8, size: ?u64, meta: ?FileMeta) void {
588
622
  for (self.s.excluded.items) |ext| {
589
- if (ext.len > 0 and hasSuffixIgnoreCase(name, ext)) {
623
+ if (ext.len == 0) continue;
624
+ if (ext[0] == '/') {
625
+ if (name.len == ext.len - 1 and hasSuffixIgnoreCase(name, ext[1..])) {
626
+ _ = self.s.skipped.fetchAdd(1, .monotonic);
627
+ return;
628
+ }
629
+ } else if (hasSuffixIgnoreCase(name, ext)) {
590
630
  _ = self.s.skipped.fetchAdd(1, .monotonic);
591
631
  return;
592
632
  }
593
633
  }
634
+
635
+ // ファイル一覧モード(fd 互換): ファイルを開かずにパスのみ記録
636
+ if (self.s.files_only) {
637
+ self.record(.match, job.root, 1, 0, job.path(), name, "");
638
+ if (self.out.len >= LOCAL_FLUSH) self.s.publish(&self.out);
639
+ return;
640
+ }
594
641
  const ext = extname(name);
595
642
  if (ext.len > 0) {
596
643
  for (self.s.documents.items) |doc| {
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rufio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.1
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - masisz
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-11-08 00:00:00.000000000 Z
11
+ date: 2026-11-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: io-console