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 +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +71 -0
- data/docs/adr/000-template.md +17 -0
- data/docs/adr/001-duck-typed-ignore-rules.md +22 -0
- data/docs/adr/README.md +3 -0
- data/lib/alkaid/match_data_compat.rb +17 -0
- data/lib/alkaid/regexp_compat.rb +42 -0
- data/lib/alkaid/search.rb +204 -0
- data/lib/alkaid/search_pool.rb +179 -0
- data/lib/alkaid/search_worker/cancelled.rb +3 -0
- data/lib/alkaid/search_worker/protocol.rb +93 -0
- data/lib/alkaid/search_worker/runner.rb +5 -0
- data/lib/alkaid/search_worker.rb +165 -0
- data/lib/alkaid/version.rb +5 -0
- data/lib/alkaid/walker.rb +88 -0
- data/lib/alkaid.rb +55 -0
- data/sig/alkaid.rbs +52 -0
- metadata +61 -0
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.
|
data/docs/adr/README.md
ADDED
|
@@ -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,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,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,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: []
|