canopus 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 +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +148 -0
- data/docs/adr/001-runtime-gem-components.md +24 -0
- data/docs/adr/002-bounded-diff-engine.md +22 -0
- data/docs/adr/003-background-language-analysis.md +22 -0
- data/docs/adr/004-persistent-display-map.md +23 -0
- data/docs/adr/005-windows-runtime.md +27 -0
- data/docs/adr/README.md +36 -0
- data/docs/distribution.md +27 -0
- data/docs/lsp.md +38 -0
- data/docs/performance.md +40 -0
- data/docs/snippets.md +32 -0
- data/docs/vim.md +24 -0
- data/docs/workspace_edits.md +32 -0
- data/examples/native_smoke.rb +25 -0
- data/examples/plugins/word_count.rb +11 -0
- data/exe/canopus +19 -0
- data/lib/canopus/block_map.rb +19 -0
- data/lib/canopus/buffer.rb +293 -0
- data/lib/canopus/cli.rb +171 -0
- data/lib/canopus/controller.rb +507 -0
- data/lib/canopus/data_compat.rb +25 -0
- data/lib/canopus/display_map/line_builder.rb +54 -0
- data/lib/canopus/display_map/line_set.rb +8 -0
- data/lib/canopus/display_map/pending_line_set.rb +9 -0
- data/lib/canopus/display_map/summary.rb +23 -0
- data/lib/canopus/display_map/worker.rb +89 -0
- data/lib/canopus/display_map.rb +350 -0
- data/lib/canopus/display_point.rb +5 -0
- data/lib/canopus/editor/snippet_expandable.rb +281 -0
- data/lib/canopus/editor.rb +445 -0
- data/lib/canopus/error.rb +5 -0
- data/lib/canopus/fold_map.rb +87 -0
- data/lib/canopus/git/blame.rb +50 -0
- data/lib/canopus/git/commit.rb +7 -0
- data/lib/canopus/git/corrupt_object.rb +7 -0
- data/lib/canopus/git/diff.rb +131 -0
- data/lib/canopus/git/index.rb +94 -0
- data/lib/canopus/git/object_database.rb +45 -0
- data/lib/canopus/git/pack.rb +186 -0
- data/lib/canopus/git/repository.rb +313 -0
- data/lib/canopus/git/status.rb +74 -0
- data/lib/canopus/git/tree_entry.rb +7 -0
- data/lib/canopus/git.rb +15 -0
- data/lib/canopus/icon_theme.rb +43 -0
- data/lib/canopus/language/background_analysis/job.rb +12 -0
- data/lib/canopus/language/background_analysis/scheduler.rb +70 -0
- data/lib/canopus/language/background_analysis.rb +280 -0
- data/lib/canopus/language/definition.rb +7 -0
- data/lib/canopus/language/document.rb +143 -0
- data/lib/canopus/language/symbol.rb +7 -0
- data/lib/canopus/language/syntax_worker.rb +91 -0
- data/lib/canopus/language.rb +42 -0
- data/lib/canopus/lazy_rope.rb +218 -0
- data/lib/canopus/lsp/client.rb +299 -0
- data/lib/canopus/lsp/error.rb +7 -0
- data/lib/canopus/lsp/future/subscription.rb +9 -0
- data/lib/canopus/lsp/future.rb +87 -0
- data/lib/canopus/lsp/protocol.rb +83 -0
- data/lib/canopus/lsp/server_error.rb +13 -0
- data/lib/canopus/lsp/timeout.rb +7 -0
- data/lib/canopus/lsp/transport.rb +123 -0
- data/lib/canopus/lsp.rb +19 -0
- data/lib/canopus/markdown.rb +69 -0
- data/lib/canopus/match_data_compat.rb +17 -0
- data/lib/canopus/multi_buffer.rb +266 -0
- data/lib/canopus/pane.rb +61 -0
- data/lib/canopus/patch/composite.rb +12 -0
- data/lib/canopus/patch/reload.rb +18 -0
- data/lib/canopus/patch.rb +27 -0
- data/lib/canopus/performance_recorder.rb +207 -0
- data/lib/canopus/plugins/api.rb +50 -0
- data/lib/canopus/plugins/isolated_runtime.rb +167 -0
- data/lib/canopus/plugins/local_runtime.rb +25 -0
- data/lib/canopus/plugins/permission_denied.rb +7 -0
- data/lib/canopus/plugins/registry.rb +30 -0
- data/lib/canopus/plugins.rb +11 -0
- data/lib/canopus/project/ignore_matcher.rb +104 -0
- data/lib/canopus/project/search.rb +164 -0
- data/lib/canopus/project/search_worker/cancelled.rb +3 -0
- data/lib/canopus/project/search_worker/runner.rb +9 -0
- data/lib/canopus/project/search_worker.rb +108 -0
- data/lib/canopus/project/tree.rb +48 -0
- data/lib/canopus/project/watcher.rb +59 -0
- data/lib/canopus/project.rb +127 -0
- data/lib/canopus/regexp_compat.rb +30 -0
- data/lib/canopus/save_conflict.rb +5 -0
- data/lib/canopus/selection.rb +11 -0
- data/lib/canopus/settings.rb +118 -0
- data/lib/canopus/snippet/transform.rb +209 -0
- data/lib/canopus/snippet.rb +183 -0
- data/lib/canopus/tab_map.rb +30 -0
- data/lib/canopus/terminal/cell.rb +7 -0
- data/lib/canopus/terminal/grid.rb +321 -0
- data/lib/canopus/terminal/pty.rb +103 -0
- data/lib/canopus/terminal/scrollback.rb +38 -0
- data/lib/canopus/terminal/vt.rb +398 -0
- data/lib/canopus/terminal.rb +13 -0
- data/lib/canopus/theme.rb +43 -0
- data/lib/canopus/version.rb +5 -0
- data/lib/canopus/vim/commandable.rb +148 -0
- data/lib/canopus/vim/motionable.rb +321 -0
- data/lib/canopus/vim/operator_capable.rb +377 -0
- data/lib/canopus/vim/text_object_selectable.rb +141 -0
- data/lib/canopus/vim.rb +426 -0
- data/lib/canopus/workspace/edit/executable.rb +139 -0
- data/lib/canopus/workspace/edit/node.rb +8 -0
- data/lib/canopus/workspace/edit/plan.rb +170 -0
- data/lib/canopus/workspace/edit/resource_preparable.rb +98 -0
- data/lib/canopus/workspace/edit.rb +6 -0
- data/lib/canopus/workspace/file_change_aware.rb +40 -0
- data/lib/canopus/workspace/file_previewable.rb +31 -0
- data/lib/canopus/workspace/git_aware.rb +140 -0
- data/lib/canopus/workspace/language_aware.rb +413 -0
- data/lib/canopus/workspace/language_server_configurable.rb +181 -0
- data/lib/canopus/workspace/project_searchable.rb +208 -0
- data/lib/canopus/workspace/project_tree_editable.rb +82 -0
- data/lib/canopus/workspace/session_persistable.rb +153 -0
- data/lib/canopus/workspace/settings_aware.rb +91 -0
- data/lib/canopus/workspace/view/terminal_presentable.rb +185 -0
- data/lib/canopus/workspace/view.rb +643 -0
- data/lib/canopus/workspace.rb +525 -0
- data/lib/canopus/wrap_map.rb +108 -0
- data/lib/canopus.rb +24 -0
- data/sig/canopus.rbs +375 -0
- data/sig/controller.rbs +55 -0
- data/sig/display_stages.rbs +49 -0
- data/sig/git.rbs +140 -0
- data/sig/lsp.rbs +99 -0
- data/sig/markdown.rbs +11 -0
- data/sig/performance_recorder.rbs +23 -0
- data/sig/search_services.rbs +8 -0
- data/sig/services.rbs +81 -0
- data/sig/snippet.rbs +42 -0
- data/sig/terminal.rbs +125 -0
- data/sig/vim.rbs +27 -0
- data/sig/workspace_edits.rbs +11 -0
- data/sig/workspace_services.rbs +117 -0
- data/tools/certify_snippet_regex.rb +35 -0
- data/tools/check_dependencies.rb +80 -0
- data/tools/native_check.rb +70 -0
- data/tools/package.rb +113 -0
- data/tools/package_test.rb +32 -0
- metadata +314 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
require_relative "search_worker"
|
|
5
|
+
|
|
6
|
+
class Canopus::Project::Search
|
|
7
|
+
SearchWorker = Canopus::Project.const_get(:SearchWorker, false)
|
|
8
|
+
private_constant :SearchWorker
|
|
9
|
+
|
|
10
|
+
Match = Struct.new(:path, :line, :column, :byte_offset, :text, :match, keyword_init: true)
|
|
11
|
+
DEFAULT_MAX_SIZE = 10 * 1024 * 1024
|
|
12
|
+
Child = Struct.new(:pid, :input, :output, :queue, :writer, :reader, :waiter)
|
|
13
|
+
private_constant :Child
|
|
14
|
+
|
|
15
|
+
def initialize(project)
|
|
16
|
+
@project = project
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Results are ordered by relative path and byte offset, even with workers.
|
|
20
|
+
def call(pattern, workers: 4, max_size: DEFAULT_MAX_SIZE, extensions: nil, limit: nil,
|
|
21
|
+
case_sensitive: true, cancelled: nil, paths: nil, &block)
|
|
22
|
+
raise ArgumentError, "workers must be between 1 and 32" unless workers.is_a?(Integer) && workers.between?(1, 32)
|
|
23
|
+
raise ArgumentError, "limit must be positive" if limit && (!limit.is_a?(Integer) || !limit.positive?)
|
|
24
|
+
raise ArgumentError, "max_size must be nonnegative" if max_size && (!max_size.is_a?(Integer) || max_size.negative?)
|
|
25
|
+
max_size = [max_size || SearchWorker::MAX_FILE_BYTES, SearchWorker::MAX_FILE_BYTES].min
|
|
26
|
+
expression = pattern.is_a?(Regexp) ? pattern : Regexp.new(Regexp.escape(pattern), case_sensitive ? 0 : Regexp::IGNORECASE)
|
|
27
|
+
expression = Regexp.new(expression.source, expression.options, timeout: expression.timeout || 0.25)
|
|
28
|
+
files = []
|
|
29
|
+
(paths || @project.files(extensions: extensions, max_size: max_size)).each do |relative|
|
|
30
|
+
SearchWorker.check_cancelled(cancelled)
|
|
31
|
+
raise ArgumentError, "invalid search path" unless relative.is_a?(String) && relative.valid_encoding? && !relative.include?("\0")
|
|
32
|
+
@project.path(relative)
|
|
33
|
+
files << relative
|
|
34
|
+
end
|
|
35
|
+
files.sort!
|
|
36
|
+
return [] if files.empty?
|
|
37
|
+
results = []
|
|
38
|
+
receive = receiver(results)
|
|
39
|
+
if workers > 1 && files.length > 1
|
|
40
|
+
parallel(files, expression, [workers, files.length].min, max_size, limit, cancelled, &receive)
|
|
41
|
+
else
|
|
42
|
+
SearchWorker.scan(@project.root, files, expression, max_size, limit, cancelled: cancelled, &receive)
|
|
43
|
+
end
|
|
44
|
+
SearchWorker.check_cancelled(cancelled)
|
|
45
|
+
results.each(&block) if block
|
|
46
|
+
results
|
|
47
|
+
rescue SearchWorker::Cancelled
|
|
48
|
+
[]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def receiver(results)
|
|
54
|
+
path = number = offset = raw = text = nil
|
|
55
|
+
lambda do |message|
|
|
56
|
+
case message[0]
|
|
57
|
+
when :line
|
|
58
|
+
_, path, number, offset, raw = message
|
|
59
|
+
text = raw.chomp.freeze
|
|
60
|
+
when :matches
|
|
61
|
+
message[1].each do |column, first, last|
|
|
62
|
+
results << Match.new(path: path, line: number, column: column,
|
|
63
|
+
byte_offset: offset + first, text: text, match: raw.byteslice(first, last - first))
|
|
64
|
+
end
|
|
65
|
+
when :error then raise IOError, "search worker: #{message[1]}"
|
|
66
|
+
when :done then nil
|
|
67
|
+
else raise IOError, "invalid search worker response"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parallel(files, expression, count, max_size, limit, cancelled)
|
|
73
|
+
children, first = [], 0
|
|
74
|
+
count.times do |index|
|
|
75
|
+
SearchWorker.check_cancelled(cancelled)
|
|
76
|
+
length = files.length / count + (index < files.length % count ? 1 : 0)
|
|
77
|
+
config = [@project.root, files.slice(first, length), expression, max_size, limit, expression.timeout]
|
|
78
|
+
children << start_child(config)
|
|
79
|
+
first += length
|
|
80
|
+
end
|
|
81
|
+
remaining = limit
|
|
82
|
+
children.each do |child|
|
|
83
|
+
loop do
|
|
84
|
+
SearchWorker.check_cancelled(cancelled)
|
|
85
|
+
begin
|
|
86
|
+
message = child.queue.pop(true)
|
|
87
|
+
rescue ThreadError
|
|
88
|
+
sleep 0.005
|
|
89
|
+
next
|
|
90
|
+
end
|
|
91
|
+
raise message if message.is_a?(Exception)
|
|
92
|
+
break if message[0] == :done
|
|
93
|
+
if message[0] == :matches && remaining
|
|
94
|
+
message[1] = message[1].first(remaining)
|
|
95
|
+
remaining -= message[1].length
|
|
96
|
+
end
|
|
97
|
+
yield message
|
|
98
|
+
return if remaining == 0
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
ensure
|
|
102
|
+
children&.each { |child| stop_child(child) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def start_child(config)
|
|
106
|
+
child_input, input = IO.pipe
|
|
107
|
+
output, child_output = IO.pipe
|
|
108
|
+
child = Child.new(nil, input, output, SizedQueue.new(2))
|
|
109
|
+
child.pid = Process.spawn({"RUBYOPT" => nil, "RUBYLIB" => nil}, RbConfig.ruby, "--disable-gems",
|
|
110
|
+
File.expand_path("search_worker/runner.rb", __dir__), in: child_input, out: child_output, err: File::NULL)
|
|
111
|
+
child.waiter = Process.detach(child.pid)
|
|
112
|
+
child_input.close
|
|
113
|
+
child_output.close
|
|
114
|
+
child.writer = Thread.new do
|
|
115
|
+
SearchWorker.write_frame(input, config)
|
|
116
|
+
rescue StandardError => error
|
|
117
|
+
enqueue(child, error)
|
|
118
|
+
ensure
|
|
119
|
+
input.close unless input.closed?
|
|
120
|
+
end
|
|
121
|
+
child.reader = Thread.new do
|
|
122
|
+
loop do
|
|
123
|
+
message = SearchWorker.read_frame(output)
|
|
124
|
+
break unless enqueue(child, message)
|
|
125
|
+
break if message[0] == :done
|
|
126
|
+
end
|
|
127
|
+
rescue StandardError => error
|
|
128
|
+
enqueue(child, error)
|
|
129
|
+
end
|
|
130
|
+
child
|
|
131
|
+
rescue Exception
|
|
132
|
+
stop_child(child) if child
|
|
133
|
+
[child_input, child_output, input, output].compact.each { |io| io.close unless io.closed? }
|
|
134
|
+
raise
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def stop_child(child)
|
|
138
|
+
child.queue.close
|
|
139
|
+
if child.waiter && !child.waiter.join(0)
|
|
140
|
+
signal(child, "TERM")
|
|
141
|
+
unless child.waiter.join(0.2)
|
|
142
|
+
signal(child, "KILL")
|
|
143
|
+
child.waiter.join(0.5)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
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
|
+
end
|
|
152
|
+
|
|
153
|
+
def enqueue(child, message)
|
|
154
|
+
child.queue.push(message)
|
|
155
|
+
rescue ClosedQueueError
|
|
156
|
+
false
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def signal(child, name)
|
|
160
|
+
Process.kill(name, child.pid)
|
|
161
|
+
rescue Errno::ESRCH, Errno::EINVAL
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../regexp_compat"
|
|
4
|
+
require_relative "../match_data_compat"
|
|
5
|
+
|
|
6
|
+
# This protocol only connects this file to its own Ruby parent. Marshal is
|
|
7
|
+
# never accepted from a file, network peer, plugin, or external executable.
|
|
8
|
+
module Canopus::Project::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
|
+
def self.check_cancelled(callback)
|
|
15
|
+
raise Cancelled if callback&.call
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.write_frame(io, value)
|
|
19
|
+
bytes = Marshal.dump(value)
|
|
20
|
+
raise IOError, "search frame exceeds safety limit" if bytes.bytesize > MAX_FRAME_BYTES
|
|
21
|
+
io.write([bytes.bytesize].pack("N"))
|
|
22
|
+
io.write(bytes)
|
|
23
|
+
io.flush
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.read_frame(io)
|
|
27
|
+
header = io.read(4)
|
|
28
|
+
raise EOFError, "search worker ended before completion" unless header && header.bytesize == 4
|
|
29
|
+
size = header.unpack1("N")
|
|
30
|
+
raise IOError, "invalid search frame size" unless size.between?(1, MAX_FRAME_BYTES)
|
|
31
|
+
bytes = io.read(size)
|
|
32
|
+
raise EOFError, "truncated search frame" unless bytes && bytes.bytesize == size
|
|
33
|
+
Marshal.load(bytes)
|
|
34
|
+
rescue TypeError, ArgumentError => error
|
|
35
|
+
raise IOError, "invalid search frame: #{error.message}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.scan(root, paths, expression, max_size, limit, cancelled: nil)
|
|
39
|
+
total = 0
|
|
40
|
+
paths.each do |relative|
|
|
41
|
+
check_cancelled(cancelled)
|
|
42
|
+
absolute = File.expand_path(relative, root)
|
|
43
|
+
raise ArgumentError, "path outside project" unless absolute.start_with?(root + File::SEPARATOR)
|
|
44
|
+
source = read_source(absolute, max_size, cancelled)
|
|
45
|
+
next unless source
|
|
46
|
+
offset = 0
|
|
47
|
+
source.each_line.with_index(1) do |line, number|
|
|
48
|
+
check_cancelled(cancelled)
|
|
49
|
+
matches, sent_line = [], false
|
|
50
|
+
Canopus.with_regexp_timeout(expression) do
|
|
51
|
+
line.to_enum(:scan, expression).each do
|
|
52
|
+
match = Regexp.last_match
|
|
53
|
+
check_cancelled(cancelled)
|
|
54
|
+
unless sent_line
|
|
55
|
+
yield [:line, relative, number, offset, line]
|
|
56
|
+
sent_line = true
|
|
57
|
+
end
|
|
58
|
+
first = match.respond_to?(:bytebegin) ? match.bytebegin(0) : match.pre_match.bytesize
|
|
59
|
+
matches << [match.begin(0) + 1, first, first + match[0].bytesize]
|
|
60
|
+
total += 1
|
|
61
|
+
if matches.length == MATCH_BATCH || (limit && total >= limit)
|
|
62
|
+
yield [:matches, matches]
|
|
63
|
+
matches = []
|
|
64
|
+
end
|
|
65
|
+
return if limit && total >= limit
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
yield [:matches, matches] unless matches.empty?
|
|
69
|
+
offset += line.bytesize
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.read_source(path, max_size, cancelled)
|
|
75
|
+
flags = File::RDONLY
|
|
76
|
+
# Windows defines NONBLOCK as 1, which aliases WRONLY in File.open.
|
|
77
|
+
flags |= File::NONBLOCK unless RUBY_PLATFORM.match?(/mswin|mingw/)
|
|
78
|
+
File.open(path, flags) do |file|
|
|
79
|
+
file.binmode
|
|
80
|
+
return unless file.stat.file? && file.size <= max_size
|
|
81
|
+
source = +"".b
|
|
82
|
+
while (chunk = file.read(READ_BYTES))
|
|
83
|
+
check_cancelled(cancelled)
|
|
84
|
+
source << chunk
|
|
85
|
+
return if source.bytesize > max_size || chunk.include?("\0")
|
|
86
|
+
end
|
|
87
|
+
source.force_encoding(Encoding::UTF_8)
|
|
88
|
+
source if source.valid_encoding?
|
|
89
|
+
end
|
|
90
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::EISDIR, Errno::ELOOP
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.run
|
|
95
|
+
STDIN.binmode
|
|
96
|
+
STDOUT.binmode
|
|
97
|
+
root, paths, expression, max_size, limit, timeout = read_frame(STDIN)
|
|
98
|
+
expression = Regexp.new(expression.source, expression.options, timeout: timeout)
|
|
99
|
+
scan(root, paths, expression, max_size, limit) { |message| write_frame(STDOUT, message) }
|
|
100
|
+
write_frame(STDOUT, [:done])
|
|
101
|
+
rescue StandardError => error
|
|
102
|
+
write_frame(STDOUT, [:error, "#{error.class}: #{error.message}".byteslice(0, 4096)])
|
|
103
|
+
write_frame(STDOUT, [:done])
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
require_relative "search_worker/cancelled"
|
|
108
|
+
Canopus::Project.send(:private_constant, :SearchWorker)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Canopus
|
|
6
|
+
class Project::Tree
|
|
7
|
+
Entry = Data.define(:path, :name, :depth, :directory, :expanded)
|
|
8
|
+
attr_reader :expanded
|
|
9
|
+
def initialize(files, expanded: Set.new)
|
|
10
|
+
@expanded, @children = expanded, Hash.new { |hash, key| hash[key] = {} }
|
|
11
|
+
files.each do |path|
|
|
12
|
+
parent = ""
|
|
13
|
+
pieces = path.split("/")
|
|
14
|
+
pieces.each_with_index do |piece, index|
|
|
15
|
+
current = parent.empty? ? piece : "#{parent}/#{piece}"
|
|
16
|
+
@children[parent][piece] = [current, index < pieces.length - 1 || path.end_with?("/")]
|
|
17
|
+
parent = current
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
@children.transform_values! { |children| children.sort_by { |name, (_, directory)| [directory ? 0 : 1, name.downcase, name] } }
|
|
21
|
+
end
|
|
22
|
+
def toggle(path)
|
|
23
|
+
@expanded.include?(path) ? @expanded.delete(path) : @expanded.add(path)
|
|
24
|
+
@visible = nil
|
|
25
|
+
end
|
|
26
|
+
def reveal(path)
|
|
27
|
+
pieces = path.split("/")[0...-1]
|
|
28
|
+
pieces.length.times { |index| @expanded.add(pieces.first(index + 1).join("/")) }
|
|
29
|
+
@visible = nil
|
|
30
|
+
end
|
|
31
|
+
def visible
|
|
32
|
+
@visible ||= begin
|
|
33
|
+
output, stack = [], [["", 0]]
|
|
34
|
+
until stack.empty?
|
|
35
|
+
path, depth = stack.pop
|
|
36
|
+
@children.fetch(path, []).reverse_each do |name, (child, directory)|
|
|
37
|
+
stack << [Entry.new(child, name, depth, directory, @expanded.include?(child)), nil]
|
|
38
|
+
end if path.is_a?(String)
|
|
39
|
+
if path.is_a?(Entry)
|
|
40
|
+
output << path
|
|
41
|
+
stack << [path.path, path.depth + 1] if path.directory && path.expanded
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
output.freeze
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Canopus::Project::Watcher
|
|
4
|
+
Event = Struct.new(:type, :path, keyword_init: true)
|
|
5
|
+
attr_reader :backend, :snapshot
|
|
6
|
+
|
|
7
|
+
# Native adapters implement #poll(timeout:) and #close. Polling is always
|
|
8
|
+
# available and also reconciles coalesced native directory notifications.
|
|
9
|
+
def initialize(project, interval: 0.5, native: nil)
|
|
10
|
+
@project = project
|
|
11
|
+
@interval = interval
|
|
12
|
+
@native = native
|
|
13
|
+
@backend = native ? :native : :polling
|
|
14
|
+
@snapshot = capture
|
|
15
|
+
@running = false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def poll(timeout: 0)
|
|
19
|
+
if @native
|
|
20
|
+
return [] if @native.poll(timeout: timeout).empty?
|
|
21
|
+
elsif timeout.positive?
|
|
22
|
+
sleep(timeout)
|
|
23
|
+
end
|
|
24
|
+
current = capture
|
|
25
|
+
events = (@snapshot.keys - current.keys).map { |path| Event.new(type: :deleted, path: path) }
|
|
26
|
+
current.each do |path, stamp|
|
|
27
|
+
type = !@snapshot.key?(path) ? :created : @snapshot[path] != stamp ? :modified : nil
|
|
28
|
+
events << Event.new(type: type, path: path) if type
|
|
29
|
+
end
|
|
30
|
+
@snapshot = current
|
|
31
|
+
events.sort_by(&:path)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def start(&block)
|
|
35
|
+
raise ArgumentError, "watch callback required" unless block
|
|
36
|
+
return self if @running
|
|
37
|
+
@running = true
|
|
38
|
+
@thread = Thread.new { poll(timeout: @interval).each(&block) while @running }
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def close
|
|
43
|
+
@running = false
|
|
44
|
+
@thread&.join unless @thread == Thread.current
|
|
45
|
+
@native&.close
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def capture
|
|
52
|
+
@project.files.to_h do |relative|
|
|
53
|
+
stat = File.stat(@project.path(relative))
|
|
54
|
+
[relative, [stat.ino, stat.size, stat.mtime, stat.ctime]]
|
|
55
|
+
rescue Errno::ENOENT
|
|
56
|
+
[relative, nil]
|
|
57
|
+
end.reject { |_, stamp| stamp.nil? }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
module Canopus
|
|
8
|
+
class Project
|
|
9
|
+
attr_reader :root
|
|
10
|
+
|
|
11
|
+
def initialize(root)
|
|
12
|
+
@root = File.realpath(root)
|
|
13
|
+
raise ArgumentError, "project root must be a directory" unless File.directory?(@root)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def path(relative)
|
|
17
|
+
absolute = File.expand_path(relative, root)
|
|
18
|
+
raise ArgumentError, "path outside project" unless absolute == root || absolute.start_with?(root + File::SEPARATOR)
|
|
19
|
+
absolute
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Paths are relative to root, sorted, and never include .git directories.
|
|
23
|
+
# Directory symlinks are opt-in and visited at most once per inode.
|
|
24
|
+
def files(include_hidden: true, follow_symlinks: false, include_symlinks: false, include_directories: false, extensions: nil, max_size: nil)
|
|
25
|
+
return enum_for(__method__, include_hidden: include_hidden, follow_symlinks: follow_symlinks,
|
|
26
|
+
include_symlinks: include_symlinks, include_directories: include_directories, extensions: extensions, max_size: max_size) unless block_given?
|
|
27
|
+
extensions = extensions&.map { |extension| extension.start_with?(".") ? extension : ".#{extension}" }&.to_set
|
|
28
|
+
visited = Set.new
|
|
29
|
+
base_rules = IgnoreMatcher.new
|
|
30
|
+
exclude = File.join(root, ".git", "info", "exclude")
|
|
31
|
+
base_rules = base_rules.add(File.read(exclude), base: "") if File.file?(exclude)
|
|
32
|
+
walk = lambda do |directory, rules|
|
|
33
|
+
stat = File.stat(path(directory))
|
|
34
|
+
return unless visited.add?([stat.dev, stat.ino])
|
|
35
|
+
%w[.gitignore .ignore].each do |name|
|
|
36
|
+
source = path(directory.empty? ? name : File.join(directory, name))
|
|
37
|
+
rules = rules.add(File.read(source, encoding: "UTF-8"), base: directory) if File.file?(source)
|
|
38
|
+
end
|
|
39
|
+
Dir.children(path(directory)).sort.each do |name|
|
|
40
|
+
next if name == ".git" || (!include_hidden && name.start_with?("."))
|
|
41
|
+
relative = directory.empty? ? name : File.join(directory, name)
|
|
42
|
+
next if relative == ".canopus/trash"
|
|
43
|
+
absolute = path(relative)
|
|
44
|
+
begin
|
|
45
|
+
entry = File.lstat(absolute)
|
|
46
|
+
symbolic = entry.symlink?
|
|
47
|
+
if symbolic && !follow_symlinks
|
|
48
|
+
next unless include_symlinks
|
|
49
|
+
next if rules.ignored?(relative)
|
|
50
|
+
next if extensions && !extensions.include?(File.extname(name))
|
|
51
|
+
yield relative
|
|
52
|
+
next
|
|
53
|
+
end
|
|
54
|
+
entry = File.stat(absolute) if symbolic
|
|
55
|
+
next if rules.ignored?(relative, directory: entry.directory?)
|
|
56
|
+
if entry.directory?
|
|
57
|
+
yield relative + "/" if include_directories
|
|
58
|
+
walk.call(relative, rules)
|
|
59
|
+
elsif entry.file?
|
|
60
|
+
next if max_size && entry.size > max_size
|
|
61
|
+
next if extensions && !extensions.include?(File.extname(name))
|
|
62
|
+
yield relative
|
|
63
|
+
end
|
|
64
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
|
|
65
|
+
next # An editor walk tolerates concurrent deletion and inaccessible files.
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
walk.call("", base_rules)
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def ignored?(relative, directory: File.directory?(path(relative)))
|
|
74
|
+
pieces = relative.sub(%r{\A\./}, "").split("/")
|
|
75
|
+
rules = IgnoreMatcher.new
|
|
76
|
+
base = ""
|
|
77
|
+
exclude = File.join(root, ".git", "info", "exclude")
|
|
78
|
+
rules = rules.add(File.read(exclude), base: "") if File.file?(exclude)
|
|
79
|
+
pieces.each_with_index do |piece, index|
|
|
80
|
+
return true if piece == ".git"
|
|
81
|
+
%w[.gitignore .ignore].each do |name|
|
|
82
|
+
source = path(base.empty? ? name : File.join(base, name))
|
|
83
|
+
rules = rules.add(File.read(source), base: base) if File.file?(source)
|
|
84
|
+
end
|
|
85
|
+
base = base.empty? ? piece : "#{base}/#{piece}"
|
|
86
|
+
return true if rules.ignored?(base, directory: index < pieces.length - 1 || directory)
|
|
87
|
+
end
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def search(pattern, **options, &block) = Search.new(self).call(pattern, **options, &block)
|
|
92
|
+
def watcher(**options) = Watcher.new(self, **options)
|
|
93
|
+
|
|
94
|
+
# Each file is replaced atomically; a concurrent write aborts that file.
|
|
95
|
+
def replace(pattern, replacement, **options)
|
|
96
|
+
expression = pattern.is_a?(Regexp) ? pattern : Regexp.new(Regexp.escape(pattern))
|
|
97
|
+
searcher = Search.new(self)
|
|
98
|
+
paths = searcher.call(expression, **options).map(&:path).uniq
|
|
99
|
+
paths.to_h do |relative|
|
|
100
|
+
absolute = path(relative)
|
|
101
|
+
before = File.stat(absolute)
|
|
102
|
+
text = File.read(absolute, encoding: "UTF-8")
|
|
103
|
+
count, updated = Canopus.with_regexp_timeout(expression) do
|
|
104
|
+
[text.scan(expression).length, text.gsub(expression, replacement)]
|
|
105
|
+
end
|
|
106
|
+
Tempfile.create([".canopus-", ".tmp"], File.dirname(absolute)) do |temp|
|
|
107
|
+
temp.binmode
|
|
108
|
+
temp.chmod(before.mode & 0o777)
|
|
109
|
+
temp.write(updated)
|
|
110
|
+
temp.flush
|
|
111
|
+
temp.fsync
|
|
112
|
+
temp.close
|
|
113
|
+
current = File.stat(absolute)
|
|
114
|
+
unless [before.ino, before.size, before.mtime, before.ctime] == [current.ino, current.size, current.mtime, current.ctime]
|
|
115
|
+
raise IOError, "file changed during replacement: #{relative}"
|
|
116
|
+
end
|
|
117
|
+
File.rename(temp.path, absolute)
|
|
118
|
+
end
|
|
119
|
+
[relative, count]
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
require_relative "project/ignore_matcher"
|
|
126
|
+
require_relative "project/search"
|
|
127
|
+
require_relative "project/watcher"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
unless Regexp.method_defined?(:timeout)
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
class Regexp
|
|
7
|
+
TimeoutError = Class.new(StandardError)
|
|
8
|
+
TIMEOUTS = ObjectSpace::WeakMap.new
|
|
9
|
+
|
|
10
|
+
def timeout = TIMEOUTS[self]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
Regexp.singleton_class.prepend(Module.new do
|
|
14
|
+
def new(*arguments, timeout: nil)
|
|
15
|
+
expression = super(*arguments)
|
|
16
|
+
Regexp::TIMEOUTS[expression] = timeout
|
|
17
|
+
expression
|
|
18
|
+
end
|
|
19
|
+
end)
|
|
20
|
+
|
|
21
|
+
module Canopus
|
|
22
|
+
def self.with_regexp_timeout(expression)
|
|
23
|
+
expression.timeout ? Timeout.timeout(expression.timeout, Regexp::TimeoutError) { yield } : yield
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
else
|
|
27
|
+
module Canopus
|
|
28
|
+
def self.with_regexp_timeout(_expression) = yield
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
Selection = Data.define(:id, :anchor, :head, :goal) do
|
|
5
|
+
def start = [anchor, head].min
|
|
6
|
+
def end = [anchor, head].max
|
|
7
|
+
def range = self.start...self.end
|
|
8
|
+
def empty? = anchor == head
|
|
9
|
+
def reversed? = head < anchor
|
|
10
|
+
end
|
|
11
|
+
end
|