alkaid 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5ef727b8066a385651c5f821819905bec5bcd24b167c080994ccd7e0672564a9
4
+ data.tar.gz: a6e3dc037e5e0eb6421e9f73d59fc72a935b400baf568b0c7149be2301ad6271
5
+ SHA512:
6
+ metadata.gz: 0c06a4ee2bf56f663536a97d5210758cbb7ecfc6702ea0e55f4c0330ca6105a67b09ca4b082ccfe0fcc2ca2550bc3066ee830c88da555e1b331209579439f6b5
7
+ data.tar.gz: b1167dba94c0d9c37d0a342017c146bf8af666d790a57d7f2eea1af280c632d15a52b705a64e9c9f7d91b037f58ce1397593911f7eab1a51d8565dbe032f3e7a
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-09-15
4
+
5
+ - Add deterministic recursive file walking with duck-typed ignore rules
6
+ - Add serial and process-parallel literal and regular-expression search
7
+ - Add binary, UTF-8, glob, size, cancellation, and progress handling
8
+ - Add Ruby 3.1-compatible regular-expression timeouts and byte offsets
9
+ - Support regular-expression matches spanning multiple lines
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # Alkaid
2
+
3
+ Alkaid is a pure Ruby file walker and parallel content search library. It
4
+ streams deterministic, byte-accurate matches without depending on an editor,
5
+ Git implementation, or fuzzy matcher.
6
+
7
+ ## Installation
8
+
9
+ ```ruby
10
+ gem "alkaid"
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ruby
16
+ require "alkaid"
17
+
18
+ search = Alkaid::Search.new(
19
+ Dir.pwd,
20
+ pattern: "TODO",
21
+ include: ["**/*.rb"],
22
+ workers: 4
23
+ )
24
+
25
+ search.run do |match|
26
+ puts "#{match.path}:#{match.line_number}:#{match.byte_offset}"
27
+ end
28
+ ```
29
+
30
+ `byte_offset` is the match's zero-based offset in the file. `ranges` contains
31
+ zero-based byte ranges in `line`, which retains its original line ending.
32
+ For a match spanning lines, `line` contains the complete lines touched by the
33
+ match and `line_number` identifies the first one.
34
+ Results are ordered by relative path and byte offset even when worker processes
35
+ are enabled.
36
+
37
+ Pass any ignore object that implements `ignored?(path, directory:)`. Alkaid
38
+ does not parse ignore files and does not depend on a Git library:
39
+
40
+ ```ruby
41
+ ignore = MyIgnoreMatcher.new
42
+ files = Alkaid::Walker.new(Dir.pwd, ignore: ignore).to_a
43
+ search = Alkaid::Search.new(Dir.pwd, pattern: /error/i, ignore: ignore)
44
+ ```
45
+
46
+ Literal and regular-expression searches support case folding, whole-word
47
+ matching, include/exclude globs, file-size limits, result limits, cancellation,
48
+ and optional symlink or hidden-file traversal. Binary and invalid UTF-8 files
49
+ are skipped.
50
+
51
+ ```ruby
52
+ search.cancel
53
+ progress = search.progress
54
+ ```
55
+
56
+ ## Development
57
+
58
+ ```sh
59
+ bundle install
60
+ bundle exec rake test
61
+ bundle exec rbs -I sig validate
62
+ BUDGET=1 bundle exec rake bench
63
+ gem build --strict alkaid.gemspec
64
+ ```
65
+
66
+ Use `FILES=100000 BYTES=10000 bundle exec rake bench` to exercise the full
67
+ 100,000-file, approximately 1 GB design workload.
68
+
69
+ ## License
70
+
71
+ Alkaid is available under the MIT License.
@@ -0,0 +1,17 @@
1
+ # ADR NNN: Implementation decision title
2
+
3
+ - Status: Proposed
4
+ - Date: YYYY-MM-DD
5
+
6
+ ## Context
7
+
8
+ Describe the concrete implementation question and its compatibility, data,
9
+ runtime, or component constraints.
10
+
11
+ ## Decision
12
+
13
+ Describe the durable boundary or architecture choice.
14
+
15
+ ## Consequences
16
+
17
+ Describe the important positive and negative trade-offs and when to revisit it.
@@ -0,0 +1,22 @@
1
+ # ADR 001: Keep ignore rules outside Alkaid
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-15
5
+
6
+ ## Context
7
+
8
+ File search needs to skip ignored paths, but interpreting Git ignore files is a
9
+ Git responsibility. Depending on one Git implementation would couple general
10
+ filesystem search to an unrelated component and release cycle.
11
+
12
+ ## Decision
13
+
14
+ Accept ignore matchers through the single `ignored?(path, directory:)`
15
+ operation. Alkaid owns traversal and invokes the supplied matcher with paths
16
+ relative to the search root.
17
+
18
+ ## Consequences
19
+
20
+ Callers may use any ignore implementation and Alkaid has no runtime gem
21
+ dependencies. Callers that need Git-compatible rules must construct and pass a
22
+ matcher themselves.
@@ -0,0 +1,3 @@
1
+ # Architecture decision records
2
+
3
+ These records document Alkaid's durable boundaries.
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ unless MatchData.method_defined?(:byteoffset) && MatchData.method_defined?(:bytebegin)
4
+ class MatchData
5
+ unless method_defined?(:byteoffset)
6
+ def byteoffset(index)
7
+ first, last = offset(index)
8
+ first && [string[0...first].bytesize, string[0...last].bytesize]
9
+ end
10
+ end
11
+
12
+ unless method_defined?(:bytebegin)
13
+ def bytebegin(index) = byteoffset(index)&.first
14
+ def byteend(index) = byteoffset(index)&.last
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ unless Regexp.respond_to?(:timeout)
4
+ require "timeout"
5
+
6
+ class Regexp
7
+ TimeoutError = Class.new(StandardError) unless const_defined?(:TimeoutError, false)
8
+ TIMEOUTS = ObjectSpace::WeakMap.new unless const_defined?(:TIMEOUTS, false)
9
+ end
10
+
11
+ unless Regexp.method_defined?(:timeout)
12
+ class Regexp
13
+ def timeout = TIMEOUTS[self]
14
+ end
15
+
16
+ Regexp.singleton_class.prepend(Module.new do
17
+ def new(*arguments, timeout: nil)
18
+ if timeout
19
+ raise TypeError, "timeout must be numeric" unless timeout.is_a?(Numeric)
20
+
21
+ timeout = timeout.to_f
22
+ timeout = nil if timeout.nan?
23
+ raise ArgumentError, "invalid timeout" if timeout && !timeout.positive?
24
+ end
25
+ expression = super(*arguments)
26
+ Regexp::TIMEOUTS[expression] = timeout
27
+ expression
28
+ end
29
+ end)
30
+ end
31
+
32
+ module Alkaid
33
+ def self.with_regexp_timeout(expression)
34
+ timeout = expression.timeout
35
+ timeout && timeout.finite? ? Timeout.timeout(timeout, Regexp::TimeoutError) { yield } : yield
36
+ end
37
+ end
38
+ else
39
+ module Alkaid
40
+ def self.with_regexp_timeout(_expression) = yield
41
+ end
42
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thread"
4
+ require_relative "search_worker"
5
+ require_relative "search_pool"
6
+
7
+ module Alkaid
8
+ class Search
9
+ DEFAULT_MAX_FILE_SIZE = 16 * 1024 * 1024
10
+
11
+ def initialize(root, pattern:, regexp: false, ignore_case: false, whole_word: false,
12
+ include: [], exclude: [], ignore: nil, workers: 4, max_file_size: DEFAULT_MAX_FILE_SIZE,
13
+ max_matches: nil, follow_symlinks: false, hidden: false, max_depth: nil,
14
+ extensions: nil, paths: nil, cancelled: nil)
15
+ validate_options(pattern, include, exclude, ignore, workers, max_file_size, max_matches, extensions, paths,
16
+ cancelled, regexp, ignore_case, whole_word, follow_symlinks, hidden)
17
+ @root = File.realpath(root)
18
+ raise ArgumentError, "root must be a directory" unless File.directory?(@root)
19
+
20
+ @expression, @timeout = expression(pattern, regexp, ignore_case, whole_word)
21
+ @include = include.map(&:dup).map(&:freeze).freeze
22
+ @exclude = exclude.map(&:dup).map(&:freeze).freeze
23
+ @workers = workers
24
+ @max_file_size = [max_file_size || SearchWorker::MAX_FILE_BYTES, SearchWorker::MAX_FILE_BYTES].min
25
+ @max_matches = max_matches
26
+ @extensions = extensions&.map { |extension| (extension.start_with?(".") ? extension.dup : ".#{extension}").freeze }&.freeze
27
+ @paths = paths&.map(&:dup)&.map(&:freeze)&.freeze
28
+ @external_cancelled = cancelled
29
+ @walker = Walker.new(@root, ignore: ignore, follow_symlinks: follow_symlinks, hidden: hidden,
30
+ max_depth: max_depth, cancelled: method(:cancelled?))
31
+ @mutex = Mutex.new
32
+ @progress = Progress.new(files_scanned: 0, bytes_scanned: 0, matches: 0)
33
+ @cancelled = false
34
+ @running = false
35
+ end
36
+
37
+ def run
38
+ started = false
39
+ begin_run
40
+ started = true
41
+ files = search_paths
42
+ return [] if files.empty?
43
+
44
+ results = []
45
+ receive = receiver(results) { |match| yield match if block_given? }
46
+ if @workers > 1 && files.length > 1
47
+ parallel(files, [@workers, files.length].min, &receive)
48
+ else
49
+ SearchWorker.scan(@root, files, @expression, @max_file_size, @max_matches,
50
+ cancelled: method(:cancelled?), &receive)
51
+ end
52
+ SearchWorker.check_cancelled(method(:cancelled?))
53
+ results
54
+ rescue SearchWorker::Cancelled
55
+ []
56
+ ensure
57
+ @mutex.synchronize { @running = false } if started
58
+ end
59
+
60
+ def cancel
61
+ @mutex.synchronize { @cancelled = true }
62
+ nil
63
+ end
64
+
65
+ def progress = @mutex.synchronize { @progress }
66
+
67
+ private
68
+
69
+ def validate_options(pattern, includes, excludes, ignore, workers, max_file_size, max_matches, extensions, paths,
70
+ cancelled, *flags)
71
+ raise ArgumentError, "pattern must be a String or Regexp" unless pattern.is_a?(String) || pattern.is_a?(Regexp)
72
+ raise ArgumentError, "pattern must use a valid encoding" if pattern.is_a?(String) && !pattern.valid_encoding?
73
+ raise ArgumentError, "search flags must be boolean" unless flags.all? { |value| value == true || value == false }
74
+ raise ArgumentError, "workers must be between 1 and 32" unless workers.is_a?(Integer) && workers.between?(1, 32)
75
+ if max_file_size && (!max_file_size.is_a?(Integer) || max_file_size.negative?)
76
+ raise ArgumentError, "max_file_size must be nonnegative"
77
+ end
78
+ if max_matches && (!max_matches.is_a?(Integer) || !max_matches.positive?)
79
+ raise ArgumentError, "max_matches must be positive"
80
+ end
81
+ raise ArgumentError, "ignore must respond to ignored?" if ignore && !ignore.respond_to?(:ignored?)
82
+ raise ArgumentError, "cancelled must respond to call" if cancelled && !cancelled.respond_to?(:call)
83
+
84
+ {include: includes, exclude: excludes, extensions: extensions, paths: paths}.each do |name, values|
85
+ next if values.nil?
86
+ unless values.is_a?(Array) && values.all? { |value| value.is_a?(String) && value.valid_encoding? && !value.include?("\0") }
87
+ raise ArgumentError, "#{name} must contain valid strings"
88
+ end
89
+ end
90
+ end
91
+
92
+ def expression(pattern, regexp, ignore_case, whole_word)
93
+ source = pattern.is_a?(Regexp) || regexp ? pattern.to_s : Regexp.escape(pattern)
94
+ source = pattern.source if pattern.is_a?(Regexp)
95
+ source = "\\b(?:#{source})\\b" if whole_word
96
+ options = pattern.is_a?(Regexp) ? pattern.options : 0
97
+ options |= Regexp::IGNORECASE if ignore_case
98
+ timeout = pattern.respond_to?(:timeout) ? pattern.timeout : nil
99
+ timeout ||= 0.25
100
+ [Regexp.new(source, options, timeout: timeout), timeout]
101
+ end
102
+
103
+ def begin_run
104
+ @mutex.synchronize do
105
+ raise Error, "search is already running" if @running
106
+
107
+ @running = true
108
+ @cancelled = false
109
+ @progress = Progress.new(files_scanned: 0, bytes_scanned: 0, matches: 0)
110
+ end
111
+ end
112
+
113
+ def cancelled? = @cancelled || @external_cancelled&.call
114
+
115
+ def search_paths
116
+ files = []
117
+ (@paths || @walker.each).each do |relative|
118
+ SearchWorker.check_cancelled(method(:cancelled?))
119
+ validate_path(relative)
120
+ next if @extensions && !@paths && !@extensions.include?(File.extname(relative))
121
+ next unless selected?(relative)
122
+
123
+ files << relative
124
+ end
125
+ SearchWorker.check_cancelled(method(:cancelled?))
126
+ files.sort!.uniq!
127
+ files
128
+ end
129
+
130
+ def validate_path(relative)
131
+ unless relative.is_a?(String) && relative.valid_encoding? && !relative.include?("\0")
132
+ raise ArgumentError, "invalid search path"
133
+ end
134
+ normalized = File::ALT_SEPARATOR ? relative.tr(File::ALT_SEPARATOR, "/") : relative
135
+ pieces = normalized.split("/", -1)
136
+ if normalized.match?(/\A(?:\/|[A-Za-z]:\/)/) || pieces.any? { |piece| piece.empty? || piece == "." || piece == ".." }
137
+ raise ArgumentError, "invalid search path"
138
+ end
139
+
140
+ absolute = File.expand_path(relative, @root)
141
+ raise ArgumentError, "path outside root" unless inside_root?(absolute)
142
+ end
143
+
144
+ def selected?(path)
145
+ (@include.empty? || @include.any? { |pattern| glob?(pattern, path) }) &&
146
+ @exclude.none? { |pattern| glob?(pattern, path) }
147
+ end
148
+
149
+ def glob?(pattern, path)
150
+ File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH)
151
+ end
152
+
153
+ def inside_root?(path)
154
+ path == @root || path.start_with?(@root.end_with?(File::SEPARATOR) ? @root : @root + File::SEPARATOR)
155
+ end
156
+
157
+ def receiver(results)
158
+ path = number = offset = raw = line = nil
159
+ lambda do |message|
160
+ SearchWorker.validate_message(message)
161
+ case message[0]
162
+ when :line
163
+ _, path, number, offset, raw = message
164
+ validate_path(path)
165
+ line = raw.freeze
166
+ when :matches
167
+ unless path && message[1].all? { |_column, first, last| last <= line.bytesize }
168
+ raise IOError, "invalid search worker response"
169
+ end
170
+ message[1].each do |_column, first, last|
171
+ match = Match.new(path: path.freeze, line_number: number, byte_offset: offset + first,
172
+ line: line, ranges: [first...last].freeze)
173
+ results << match
174
+ update_progress(matches: 1)
175
+ yield match
176
+ end
177
+ when :progress
178
+ update_progress(files_scanned: message[1], bytes_scanned: message[2])
179
+ when :error
180
+ raise IOError, "search worker: #{message[1]}"
181
+ when :done
182
+ nil
183
+ else
184
+ raise IOError, "invalid search worker response"
185
+ end
186
+ end
187
+ end
188
+
189
+ def update_progress(files_scanned: 0, bytes_scanned: 0, matches: 0)
190
+ @mutex.synchronize do
191
+ @progress = Progress.new(files_scanned: @progress.files_scanned + files_scanned,
192
+ bytes_scanned: @progress.bytes_scanned + bytes_scanned, matches: @progress.matches + matches)
193
+ end
194
+ end
195
+
196
+ def parallel(files, count)
197
+ progress = ->(scanned, bytes) { update_progress(files_scanned: scanned, bytes_scanned: bytes) }
198
+ SearchPool.new(@root, @expression, @timeout, @max_file_size, @max_matches, method(:cancelled?)).run(files, count,
199
+ progress: progress) do |message|
200
+ yield message
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require "thread"
5
+ require_relative "search_worker"
6
+
7
+ module Alkaid
8
+ class SearchPool
9
+ BATCH_FILES = SearchWorker::MATCH_BATCH
10
+ Child = Struct.new(:pid, :input, :output, :queue, :lock, :ready, :writer, :reader, :waiter)
11
+ private_constant :Child
12
+
13
+ def initialize(root, expression, timeout, max_size, limit, cancelled)
14
+ @root = root
15
+ @expression = expression
16
+ @timeout = timeout
17
+ @max_size = max_size
18
+ @limit = limit
19
+ @cancelled = cancelled
20
+ end
21
+
22
+ def run(files, count, progress:)
23
+ children = []
24
+ errors = []
25
+ batches = file_batches(files, count)
26
+ paths = Array.new(count) { [] }
27
+ batch_sizes = Array.new(count) { [] }
28
+ batches.each_with_index do |batch, index|
29
+ owner = index % count
30
+ paths[owner].concat(batch)
31
+ batch_sizes[owner] << batch.length
32
+ end
33
+ count.times do |index|
34
+ SearchWorker.check_cancelled(@cancelled)
35
+ children << start_child(paths[index], batch_sizes[index], progress)
36
+ end
37
+
38
+ remaining = @limit
39
+ active = Array.new(count, true)
40
+ batches.each_index do |index|
41
+ owner = index % count
42
+ next unless active[owner]
43
+
44
+ status, remaining = drain(children[owner], :batch, remaining, errors) { |message| yield message }
45
+ raise errors.first if status == :limit && !errors.empty?
46
+ return if status == :limit
47
+ active[owner] = false if status == :failed
48
+ end
49
+ children.each_with_index do |child, index|
50
+ next unless active[index]
51
+
52
+ status, remaining = drain(child, :done, remaining, errors) { |message| yield message }
53
+ raise errors.first if status == :limit && !errors.empty?
54
+ return if status == :limit
55
+ end
56
+ raise errors.first unless errors.empty?
57
+ ensure
58
+ children&.each { |child| stop_child(child) }
59
+ end
60
+
61
+ private
62
+
63
+ def file_batches(files, workers)
64
+ size = [[files.length / workers, 1].max, BATCH_FILES].min
65
+ files.each_slice(size).to_a
66
+ end
67
+
68
+ def drain(child, boundary, remaining, errors)
69
+ loop do
70
+ message = next_message(child)
71
+ if message.is_a?(Exception)
72
+ errors << message
73
+ return [:failed, remaining]
74
+ end
75
+ if message[0] == :error
76
+ errors << IOError.new("search worker: #{message[1]}")
77
+ return [:failed, remaining]
78
+ end
79
+ return [:complete, remaining] if message[0] == boundary
80
+ if [:batch, :done].include?(message[0]) || boundary == :done
81
+ errors << IOError.new("invalid search worker sequence")
82
+ return [:failed, remaining]
83
+ end
84
+ if message[0] == :matches && remaining
85
+ message[1] = message[1].first(remaining)
86
+ remaining -= message[1].length
87
+ end
88
+ yield message
89
+ return [:limit, remaining] if remaining == 0
90
+ end
91
+ end
92
+
93
+ def next_message(child)
94
+ loop do
95
+ SearchWorker.check_cancelled(@cancelled)
96
+ child.lock.synchronize do
97
+ begin
98
+ return child.queue.pop(true)
99
+ rescue ThreadError
100
+ child.ready.wait(child.lock, 0.005)
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ def start_child(files, batch_sizes, progress)
107
+ child_input, input = IO.pipe
108
+ output, child_output = IO.pipe
109
+ child = Child.new(nil, input, output, SizedQueue.new(2), Mutex.new, ConditionVariable.new)
110
+ config = [@root, files, @expression.source, @expression.options, @max_size, @limit, @timeout, batch_sizes]
111
+ child.pid = Process.spawn({"RUBYOPT" => nil, "RUBYLIB" => nil}, RbConfig.ruby, "--disable-gems",
112
+ File.expand_path("search_worker/runner.rb", __dir__), in: child_input, out: child_output, err: File::NULL)
113
+ child.waiter = Process.detach(child.pid)
114
+ child_input.close
115
+ child_output.close
116
+ child.writer = Thread.new do
117
+ SearchWorker.write_frame(input, config)
118
+ rescue StandardError => error
119
+ enqueue(child, error)
120
+ ensure
121
+ input.close unless input.closed?
122
+ end
123
+ child.reader = Thread.new do
124
+ loop do
125
+ message = SearchWorker.read_frame(output)
126
+ SearchWorker.validate_message(message)
127
+ if message[0] == :progress
128
+ progress.call(message[1], message[2])
129
+ next
130
+ end
131
+ break unless enqueue(child, message)
132
+ break if message[0] == :done
133
+ end
134
+ rescue StandardError => error
135
+ enqueue(child, error)
136
+ end
137
+ child
138
+ rescue Exception
139
+ stop_child(child) if child
140
+ [child_input, child_output, input, output].compact.each { |io| io.close unless io.closed? }
141
+ raise
142
+ end
143
+
144
+ def stop_child(child)
145
+ child.queue.close
146
+ [child.input, child.output].compact.each { |io| io.close unless io.closed? }
147
+ [child.writer, child.reader].compact.each do |thread|
148
+ thread.kill unless thread.join(0.2)
149
+ thread.join(0.1)
150
+ end
151
+ if child.waiter && !child.waiter.join(0)
152
+ signal(child, "TERM")
153
+ unless child.waiter.join(0.2)
154
+ signal(child, "KILL")
155
+ child.waiter.join
156
+ end
157
+ elsif child.pid && !child.waiter
158
+ signal(child, "KILL")
159
+ Process.waitpid(child.pid)
160
+ end
161
+ end
162
+
163
+ def enqueue(child, message)
164
+ child.queue.push(message)
165
+ child.lock.synchronize { child.ready.signal }
166
+ true
167
+ rescue ClosedQueueError
168
+ false
169
+ end
170
+
171
+ def signal(child, name)
172
+ Process.kill(name, child.pid)
173
+ rescue Errno::ESRCH, Errno::EINVAL, Errno::ECHILD
174
+ nil
175
+ end
176
+ end
177
+
178
+ private_constant :SearchPool
179
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Alkaid::SearchWorker::Cancelled < StandardError; end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alkaid::SearchWorker
4
+ module_function
5
+
6
+ def write_frame(io, value)
7
+ bytes = Marshal.dump(value)
8
+ raise IOError, "search frame exceeds safety limit" if bytes.bytesize > MAX_FRAME_BYTES
9
+
10
+ io.write([bytes.bytesize].pack("N"))
11
+ io.write(bytes)
12
+ io.flush
13
+ end
14
+
15
+ def read_frame(io)
16
+ header = io.read(4)
17
+ raise EOFError, "search worker ended before completion" unless header && header.bytesize == 4
18
+
19
+ size = header.unpack1("N")
20
+ raise IOError, "invalid search frame size" unless size.between?(1, MAX_FRAME_BYTES)
21
+
22
+ bytes = io.read(size)
23
+ raise EOFError, "truncated search frame" unless bytes && bytes.bytesize == size
24
+
25
+ # The peer is always this gem's own child process, never an external source.
26
+ Marshal.load(bytes)
27
+ rescue TypeError, ArgumentError => error
28
+ raise IOError, "invalid search frame: #{error.message}"
29
+ end
30
+
31
+ def validate_config(value)
32
+ unless value.is_a?(Array) && value.length == 8
33
+ raise IOError, "invalid search worker configuration"
34
+ end
35
+
36
+ root, paths, source, options, max_size, limit, timeout, batch_sizes = value
37
+ valid_root = root.is_a?(String) && root.valid_encoding? && !root.include?("\0") && File.directory?(root)
38
+ valid_paths = paths.is_a?(Array) && paths.all? { |path| valid_path?(path) }
39
+ valid_source = source.is_a?(String) && source.valid_encoding?
40
+ valid_options = options.is_a?(Integer) && options >= 0
41
+ valid_size = max_size.is_a?(Integer) && max_size.between?(0, MAX_FILE_BYTES)
42
+ valid_limit = limit.nil? || (limit.is_a?(Integer) && limit.positive?)
43
+ valid_timeout = timeout.is_a?(Numeric) && timeout.positive?
44
+ valid_batches = batch_sizes.is_a?(Array) && batch_sizes.all? do |size|
45
+ size.is_a?(Integer) && size.between?(1, MATCH_BATCH)
46
+ end && batch_sizes.sum == paths.length
47
+ unless valid_root && valid_paths && valid_source && valid_options && valid_size && valid_limit && valid_timeout && valid_batches
48
+ raise IOError, "invalid search worker configuration"
49
+ end
50
+
51
+ value
52
+ end
53
+
54
+ def validate_message(value)
55
+ raise IOError, "invalid search worker response" unless value.is_a?(Array)
56
+
57
+ valid = case value[0]
58
+ when :line
59
+ value.length == 5 && valid_path?(value[1]) && positive_integer?(value[2]) &&
60
+ nonnegative_integer?(value[3]) && value[4].is_a?(String) && value[4].valid_encoding?
61
+ when :matches
62
+ value.length == 2 && value[1].is_a?(Array) && value[1].length <= MATCH_BATCH &&
63
+ value[1].all? do |entry|
64
+ entry.is_a?(Array) && entry.length == 3 && positive_integer?(entry[0]) &&
65
+ nonnegative_integer?(entry[1]) && nonnegative_integer?(entry[2]) && entry[1] <= entry[2]
66
+ end
67
+ when :progress
68
+ value.length == 3 && nonnegative_integer?(value[1]) && nonnegative_integer?(value[2])
69
+ when :batch
70
+ value.length == 1
71
+ when :error
72
+ value.length == 2 && value[1].is_a?(String) && value[1].valid_encoding? && value[1].bytesize <= 4096
73
+ when :done
74
+ value.length == 1
75
+ else
76
+ false
77
+ end
78
+ raise IOError, "invalid search worker response" unless valid
79
+
80
+ value
81
+ end
82
+
83
+ def valid_path?(path)
84
+ return false unless path.is_a?(String) && path.valid_encoding? && !path.include?("\0")
85
+
86
+ normalized = File::ALT_SEPARATOR ? path.tr(File::ALT_SEPARATOR, "/") : path
87
+ pieces = normalized.split("/", -1)
88
+ !normalized.match?(/\A(?:\/|[A-Za-z]:\/)/) && pieces.none? { |piece| piece.empty? || piece == "." || piece == ".." }
89
+ end
90
+
91
+ def positive_integer?(value) = value.is_a?(Integer) && value.positive?
92
+ def nonnegative_integer?(value) = value.is_a?(Integer) && value >= 0
93
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../alkaid"
4
+
5
+ Alkaid.const_get(:SearchWorker, false).run
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+ require_relative "regexp_compat"
5
+ require_relative "match_data_compat"
6
+
7
+ module Alkaid
8
+ module SearchWorker
9
+ MAX_FILE_BYTES = 64 << 20
10
+ MAX_FRAME_BYTES = MAX_FILE_BYTES + (64 << 10)
11
+ READ_BYTES = 64 << 10
12
+ MATCH_BATCH = 128
13
+
14
+ module_function
15
+
16
+ def check_cancelled(callback)
17
+ raise Cancelled if callback&.call
18
+ end
19
+
20
+ def scan(root, paths, expression, max_size, limit, cancelled: nil, batch_sizes: nil)
21
+ total = 0
22
+ files_scanned = bytes_scanned = 0
23
+ cursor = 0
24
+ batches = batch_sizes ? batch_sizes.map { |size| paths.slice(cursor, size).tap { cursor += size } } : [paths]
25
+ batches.each do |batch|
26
+ limited = false
27
+ batch.each do |relative|
28
+ check_cancelled(cancelled)
29
+ read = read_source(root, relative, max_size, cancelled)
30
+ next unless read
31
+
32
+ source, bytes = read
33
+ files_scanned += 1
34
+ bytes_scanned += bytes
35
+ next unless source
36
+
37
+ line_scanner = StringScanner.new(source)
38
+ line_start = 0
39
+ line_end = next_line_end(line_scanner, line_start)
40
+ line_number = 1
41
+ group = nil
42
+ matches = []
43
+ each_match(source, expression, cancelled) do |match|
44
+ first, last = match.byteoffset(0)
45
+ while line_end < source.bytesize && first >= line_end
46
+ line_start = line_end
47
+ line_end = next_line_end(line_scanner, line_start)
48
+ line_number += 1
49
+ end
50
+ if first == source.bytesize && !source.empty? && source.end_with?("\n")
51
+ line_start = line_end = source.bytesize
52
+ line_number += 1
53
+ end
54
+
55
+ number = line_number
56
+ offset = line_start
57
+ target = last > first ? last - 1 : first
58
+ while line_end < source.bytesize && target >= line_end
59
+ line_start = line_end
60
+ line_end = next_line_end(line_scanner, line_start)
61
+ line_number += 1
62
+ end
63
+ current_group = [number, offset, line_end]
64
+ unless group == current_group
65
+ yield [:matches, matches] unless matches.empty?
66
+ matches = []
67
+ yield [:line, relative, number, offset, source.byteslice(offset, line_end - offset)]
68
+ group = current_group
69
+ end
70
+
71
+ relative_first = first - offset
72
+ column = source.byteslice(offset, relative_first).length + 1
73
+ matches << [column, relative_first, last - offset]
74
+ total += 1
75
+ if matches.length == MATCH_BATCH || (limit && total >= limit)
76
+ yield [:matches, matches]
77
+ matches = []
78
+ end
79
+ if limit && total >= limit
80
+ limited = true
81
+ break
82
+ end
83
+ end
84
+ yield [:matches, matches] unless matches.empty?
85
+ break if limited
86
+ end
87
+ unless files_scanned.zero?
88
+ yield [:progress, files_scanned, bytes_scanned]
89
+ files_scanned = bytes_scanned = 0
90
+ end
91
+ yield [:batch] if batch_sizes
92
+ return if limited
93
+ end
94
+ end
95
+
96
+ def read_source(root, relative, max_size, cancelled)
97
+ absolute = File.expand_path(relative, root)
98
+ prefix = root.end_with?(File::SEPARATOR) ? root : root + File::SEPARATOR
99
+ raise ArgumentError, "path outside root" unless absolute.start_with?(prefix)
100
+
101
+ real = File.realpath(absolute)
102
+ raise ArgumentError, "path outside root" unless real == root || real.start_with?(prefix)
103
+
104
+ flags = File::RDONLY
105
+ # Windows defines NONBLOCK as 1, which aliases WRONLY in File.open.
106
+ windows = RUBY_PLATFORM.match?(/mswin|mingw/)
107
+ flags |= File::NONBLOCK unless windows
108
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW) && !windows
109
+ File.open(real, flags) do |file|
110
+ file.binmode
111
+ return unless file.stat.file? && file.size <= max_size
112
+
113
+ source = +"".b
114
+ while (chunk = file.read([READ_BYTES, max_size - source.bytesize + 1].min))
115
+ check_cancelled(cancelled)
116
+ source << chunk
117
+ return [nil, source.bytesize] if source.bytesize > max_size || chunk.include?("\0")
118
+ end
119
+ source.force_encoding(Encoding::UTF_8)
120
+ [source.valid_encoding? ? source : nil, source.bytesize]
121
+ end
122
+ rescue Errno::ENOENT, Errno::EACCES, Errno::EISDIR, Errno::ELOOP
123
+ nil
124
+ end
125
+
126
+ def run
127
+ STDIN.binmode
128
+ STDOUT.binmode
129
+ root, paths, source, options, max_size, limit, timeout, batch_sizes = validate_config(read_frame(STDIN))
130
+ expression = Regexp.new(source, options, timeout: timeout)
131
+ scan(root, paths, expression, max_size, limit, batch_sizes: batch_sizes) do |message|
132
+ write_frame(STDOUT, message)
133
+ end
134
+ write_frame(STDOUT, [:done])
135
+ rescue StandardError => error
136
+ message = "#{error.class}: #{error.message}".encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
137
+ write_frame(STDOUT, [:error, message.byteslice(0, 4093).scrub])
138
+ write_frame(STDOUT, [:done])
139
+ end
140
+
141
+ def each_match(source, expression, cancelled)
142
+ position = 0
143
+ loop do
144
+ check_cancelled(cancelled)
145
+ match = Alkaid.with_regexp_timeout(expression) { expression.match(source, position) }
146
+ break unless match
147
+
148
+ yield match
149
+ ending = match.end(0)
150
+ break if match.begin(0) == ending && ending == source.length
151
+
152
+ position = ending + (match.begin(0) == ending ? 1 : 0)
153
+ end
154
+ end
155
+
156
+ def next_line_end(scanner, offset)
157
+ scanner.pos = offset
158
+ scanner.skip_until(/\n/) ? scanner.pos : scanner.string.bytesize
159
+ end
160
+ end
161
+ end
162
+
163
+ require_relative "search_worker/protocol"
164
+ require_relative "search_worker/cancelled"
165
+ Alkaid.send(:private_constant, :SearchWorker)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alkaid
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Alkaid
6
+ class Walker
7
+ include Enumerable
8
+
9
+ attr_reader :root
10
+
11
+ def initialize(root, ignore: nil, follow_symlinks: false, hidden: false, max_depth: nil, cancelled: nil)
12
+ @root = File.realpath(root)
13
+ raise ArgumentError, "root must be a directory" unless File.directory?(@root)
14
+ raise ArgumentError, "ignore must respond to ignored?" if ignore && !ignore.respond_to?(:ignored?)
15
+ raise ArgumentError, "cancelled must respond to call" if cancelled && !cancelled.respond_to?(:call)
16
+ unless [follow_symlinks, hidden].all? { |value| value == true || value == false }
17
+ raise ArgumentError, "traversal flags must be boolean"
18
+ end
19
+ unless max_depth.nil? || (max_depth.is_a?(Integer) && max_depth >= 0)
20
+ raise ArgumentError, "max_depth must be nonnegative"
21
+ end
22
+
23
+ @ignore = ignore
24
+ @follow_symlinks = follow_symlinks
25
+ @hidden = hidden
26
+ @max_depth = max_depth
27
+ @cancelled = cancelled
28
+ end
29
+
30
+ def each(&block)
31
+ return enum_for(__method__) unless block
32
+
33
+ walk("", Set.new, &block)
34
+ self
35
+ end
36
+
37
+ private
38
+
39
+ def walk(directory, visited, &block)
40
+ return if @cancelled&.call
41
+
42
+ absolute_directory = absolute(directory)
43
+ stat = File.stat(absolute_directory)
44
+ return unless visited.add?([stat.dev, stat.ino])
45
+
46
+ Dir.children(absolute_directory).sort.each do |name|
47
+ return if @cancelled&.call
48
+ next if name == ".git" || (!@hidden && name.start_with?("."))
49
+
50
+ relative = directory.empty? ? name : File.join(directory, name)
51
+ normalized = File::ALT_SEPARATOR ? relative.tr(File::ALT_SEPARATOR, "/") : relative
52
+ depth = normalized.count("/") + 1
53
+ next if @max_depth && depth > @max_depth
54
+
55
+ visit(relative, normalized, depth, visited, &block)
56
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
57
+ next
58
+ end
59
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
60
+ nil
61
+ end
62
+
63
+ def visit(relative, normalized, depth, visited, &block)
64
+ path = absolute(relative)
65
+ entry = File.lstat(path)
66
+ if entry.symlink?
67
+ return unless @follow_symlinks
68
+ return unless inside_root?(File.realpath(path))
69
+
70
+ entry = File.stat(path)
71
+ end
72
+
73
+ return if @ignore&.ignored?(normalized, directory: entry.directory?)
74
+
75
+ if entry.directory?
76
+ walk(relative, visited, &block) unless @max_depth && depth >= @max_depth
77
+ elsif entry.file?
78
+ yield normalized
79
+ end
80
+ end
81
+
82
+ def absolute(relative) = relative.empty? ? root : File.join(root, relative)
83
+
84
+ def inside_root?(path)
85
+ path == root || path.start_with?(root.end_with?(File::SEPARATOR) ? root : root + File::SEPARATOR)
86
+ end
87
+ end
88
+ end
data/lib/alkaid.rb ADDED
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "alkaid/version"
4
+
5
+ module Alkaid
6
+ class Error < StandardError; end
7
+
8
+ module Value
9
+ module_function
10
+
11
+ def define(*members)
12
+ return Data.define(*members) if defined?(Data)
13
+
14
+ Struct.new(*members) do
15
+ members.each { |member| undef_method("#{member}=") }
16
+
17
+ def initialize(*values, **keywords)
18
+ if keywords.empty?
19
+ raise ArgumentError, "wrong number of arguments" unless values.length == self.class.members.length
20
+
21
+ super(*values)
22
+ else
23
+ raise ArgumentError, "cannot mix positional and keyword arguments" unless values.empty?
24
+
25
+ missing = self.class.members - keywords.keys
26
+ unknown = keywords.keys - self.class.members
27
+ raise ArgumentError, "missing keyword: #{missing.first.inspect}" unless missing.empty?
28
+ raise ArgumentError, "unknown keyword: #{unknown.first.inspect}" unless unknown.empty?
29
+
30
+ super(*self.class.members.map { |member| keywords.fetch(member) })
31
+ end
32
+ freeze
33
+ end
34
+
35
+ def with(**changes)
36
+ return self if changes.empty?
37
+
38
+ unknown = changes.keys - self.class.members
39
+ raise ArgumentError, "unknown keyword: #{unknown.first.inspect}" unless unknown.empty?
40
+
41
+ self.class.new(**to_h.merge(changes))
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ Match = Value.define(:path, :line_number, :byte_offset, :line, :ranges)
48
+ Progress = Value.define(:files_scanned, :bytes_scanned, :matches)
49
+ private_constant :Value
50
+ end
51
+
52
+ require_relative "alkaid/regexp_compat"
53
+ require_relative "alkaid/match_data_compat"
54
+ require_relative "alkaid/walker"
55
+ require_relative "alkaid/search"
data/sig/alkaid.rbs ADDED
@@ -0,0 +1,52 @@
1
+ module Alkaid
2
+ VERSION: String
3
+
4
+ interface _IgnoreMatcher
5
+ def ignored?: (String path, ?directory: bool) -> bool
6
+ end
7
+
8
+ class Error < StandardError
9
+ end
10
+
11
+ class Match
12
+ attr_reader path: String
13
+ attr_reader line_number: Integer
14
+ attr_reader byte_offset: Integer
15
+ attr_reader line: String
16
+ attr_reader ranges: Array[::Range[Integer]]
17
+ def self.new: (String path, Integer line_number, Integer byte_offset, String line, Array[::Range[Integer]] ranges) -> Match
18
+ | (path: String, line_number: Integer, byte_offset: Integer, line: String, ranges: Array[::Range[Integer]]) -> Match
19
+ def with: (?path: String, ?line_number: Integer, ?byte_offset: Integer, ?line: String, ?ranges: Array[::Range[Integer]]) -> Match
20
+ end
21
+
22
+ class Progress
23
+ attr_reader files_scanned: Integer
24
+ attr_reader bytes_scanned: Integer
25
+ attr_reader matches: Integer
26
+ def self.new: (Integer files_scanned, Integer bytes_scanned, Integer matches) -> Progress
27
+ | (files_scanned: Integer, bytes_scanned: Integer, matches: Integer) -> Progress
28
+ def with: (?files_scanned: Integer, ?bytes_scanned: Integer, ?matches: Integer) -> Progress
29
+ end
30
+
31
+ class Walker
32
+ include Enumerable[String]
33
+ attr_reader root: String
34
+ def initialize: (String root, ?ignore: _IgnoreMatcher?, ?follow_symlinks: bool, ?hidden: bool,
35
+ ?max_depth: Integer?, ?cancelled: ^() -> bool) -> void
36
+ def each: () -> Enumerator[String, self]
37
+ | () { (String) -> untyped } -> self
38
+ end
39
+
40
+ class Search
41
+ DEFAULT_MAX_FILE_SIZE: Integer
42
+ def initialize: (String root, pattern: String | Regexp, ?regexp: bool, ?ignore_case: bool,
43
+ ?whole_word: bool, ?include: Array[String], ?exclude: Array[String], ?ignore: _IgnoreMatcher?,
44
+ ?workers: Integer, ?max_file_size: Integer?, ?max_matches: Integer?, ?follow_symlinks: bool,
45
+ ?hidden: bool, ?max_depth: Integer?, ?extensions: Array[String]?, ?paths: Array[String]?,
46
+ ?cancelled: ^() -> bool) -> void
47
+ def run: () -> Array[Match]
48
+ | () { (Match) -> untyped } -> Array[Match]
49
+ def cancel: () -> nil
50
+ def progress: () -> Progress
51
+ end
52
+ end
metadata ADDED
@@ -0,0 +1,61 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: alkaid
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ email:
13
+ - t.yudai92@gmail.com
14
+ executables: []
15
+ extensions: []
16
+ extra_rdoc_files: []
17
+ files:
18
+ - CHANGELOG.md
19
+ - LICENSE.txt
20
+ - README.md
21
+ - docs/adr/000-template.md
22
+ - docs/adr/001-duck-typed-ignore-rules.md
23
+ - docs/adr/README.md
24
+ - lib/alkaid.rb
25
+ - lib/alkaid/match_data_compat.rb
26
+ - lib/alkaid/regexp_compat.rb
27
+ - lib/alkaid/search.rb
28
+ - lib/alkaid/search_pool.rb
29
+ - lib/alkaid/search_worker.rb
30
+ - lib/alkaid/search_worker/cancelled.rb
31
+ - lib/alkaid/search_worker/protocol.rb
32
+ - lib/alkaid/search_worker/runner.rb
33
+ - lib/alkaid/version.rb
34
+ - lib/alkaid/walker.rb
35
+ - sig/alkaid.rbs
36
+ homepage: https://github.com/noxdea/alkaid
37
+ licenses:
38
+ - MIT
39
+ metadata:
40
+ source_code_uri: https://github.com/noxdea/alkaid
41
+ changelog_uri: https://github.com/noxdea/alkaid/blob/main/CHANGELOG.md
42
+ allowed_push_host: https://rubygems.org
43
+ rubygems_mfa_required: 'true'
44
+ rdoc_options: []
45
+ require_paths:
46
+ - lib
47
+ required_ruby_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '3.1'
52
+ required_rubygems_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '0'
57
+ requirements: []
58
+ rubygems_version: 4.0.16
59
+ specification_version: 4
60
+ summary: Pure Ruby file walking and parallel content search
61
+ test_files: []