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.
Files changed (146) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +148 -0
  5. data/docs/adr/001-runtime-gem-components.md +24 -0
  6. data/docs/adr/002-bounded-diff-engine.md +22 -0
  7. data/docs/adr/003-background-language-analysis.md +22 -0
  8. data/docs/adr/004-persistent-display-map.md +23 -0
  9. data/docs/adr/005-windows-runtime.md +27 -0
  10. data/docs/adr/README.md +36 -0
  11. data/docs/distribution.md +27 -0
  12. data/docs/lsp.md +38 -0
  13. data/docs/performance.md +40 -0
  14. data/docs/snippets.md +32 -0
  15. data/docs/vim.md +24 -0
  16. data/docs/workspace_edits.md +32 -0
  17. data/examples/native_smoke.rb +25 -0
  18. data/examples/plugins/word_count.rb +11 -0
  19. data/exe/canopus +19 -0
  20. data/lib/canopus/block_map.rb +19 -0
  21. data/lib/canopus/buffer.rb +293 -0
  22. data/lib/canopus/cli.rb +171 -0
  23. data/lib/canopus/controller.rb +507 -0
  24. data/lib/canopus/data_compat.rb +25 -0
  25. data/lib/canopus/display_map/line_builder.rb +54 -0
  26. data/lib/canopus/display_map/line_set.rb +8 -0
  27. data/lib/canopus/display_map/pending_line_set.rb +9 -0
  28. data/lib/canopus/display_map/summary.rb +23 -0
  29. data/lib/canopus/display_map/worker.rb +89 -0
  30. data/lib/canopus/display_map.rb +350 -0
  31. data/lib/canopus/display_point.rb +5 -0
  32. data/lib/canopus/editor/snippet_expandable.rb +281 -0
  33. data/lib/canopus/editor.rb +445 -0
  34. data/lib/canopus/error.rb +5 -0
  35. data/lib/canopus/fold_map.rb +87 -0
  36. data/lib/canopus/git/blame.rb +50 -0
  37. data/lib/canopus/git/commit.rb +7 -0
  38. data/lib/canopus/git/corrupt_object.rb +7 -0
  39. data/lib/canopus/git/diff.rb +131 -0
  40. data/lib/canopus/git/index.rb +94 -0
  41. data/lib/canopus/git/object_database.rb +45 -0
  42. data/lib/canopus/git/pack.rb +186 -0
  43. data/lib/canopus/git/repository.rb +313 -0
  44. data/lib/canopus/git/status.rb +74 -0
  45. data/lib/canopus/git/tree_entry.rb +7 -0
  46. data/lib/canopus/git.rb +15 -0
  47. data/lib/canopus/icon_theme.rb +43 -0
  48. data/lib/canopus/language/background_analysis/job.rb +12 -0
  49. data/lib/canopus/language/background_analysis/scheduler.rb +70 -0
  50. data/lib/canopus/language/background_analysis.rb +280 -0
  51. data/lib/canopus/language/definition.rb +7 -0
  52. data/lib/canopus/language/document.rb +143 -0
  53. data/lib/canopus/language/symbol.rb +7 -0
  54. data/lib/canopus/language/syntax_worker.rb +91 -0
  55. data/lib/canopus/language.rb +42 -0
  56. data/lib/canopus/lazy_rope.rb +218 -0
  57. data/lib/canopus/lsp/client.rb +299 -0
  58. data/lib/canopus/lsp/error.rb +7 -0
  59. data/lib/canopus/lsp/future/subscription.rb +9 -0
  60. data/lib/canopus/lsp/future.rb +87 -0
  61. data/lib/canopus/lsp/protocol.rb +83 -0
  62. data/lib/canopus/lsp/server_error.rb +13 -0
  63. data/lib/canopus/lsp/timeout.rb +7 -0
  64. data/lib/canopus/lsp/transport.rb +123 -0
  65. data/lib/canopus/lsp.rb +19 -0
  66. data/lib/canopus/markdown.rb +69 -0
  67. data/lib/canopus/match_data_compat.rb +17 -0
  68. data/lib/canopus/multi_buffer.rb +266 -0
  69. data/lib/canopus/pane.rb +61 -0
  70. data/lib/canopus/patch/composite.rb +12 -0
  71. data/lib/canopus/patch/reload.rb +18 -0
  72. data/lib/canopus/patch.rb +27 -0
  73. data/lib/canopus/performance_recorder.rb +207 -0
  74. data/lib/canopus/plugins/api.rb +50 -0
  75. data/lib/canopus/plugins/isolated_runtime.rb +167 -0
  76. data/lib/canopus/plugins/local_runtime.rb +25 -0
  77. data/lib/canopus/plugins/permission_denied.rb +7 -0
  78. data/lib/canopus/plugins/registry.rb +30 -0
  79. data/lib/canopus/plugins.rb +11 -0
  80. data/lib/canopus/project/ignore_matcher.rb +104 -0
  81. data/lib/canopus/project/search.rb +164 -0
  82. data/lib/canopus/project/search_worker/cancelled.rb +3 -0
  83. data/lib/canopus/project/search_worker/runner.rb +9 -0
  84. data/lib/canopus/project/search_worker.rb +108 -0
  85. data/lib/canopus/project/tree.rb +48 -0
  86. data/lib/canopus/project/watcher.rb +59 -0
  87. data/lib/canopus/project.rb +127 -0
  88. data/lib/canopus/regexp_compat.rb +30 -0
  89. data/lib/canopus/save_conflict.rb +5 -0
  90. data/lib/canopus/selection.rb +11 -0
  91. data/lib/canopus/settings.rb +118 -0
  92. data/lib/canopus/snippet/transform.rb +209 -0
  93. data/lib/canopus/snippet.rb +183 -0
  94. data/lib/canopus/tab_map.rb +30 -0
  95. data/lib/canopus/terminal/cell.rb +7 -0
  96. data/lib/canopus/terminal/grid.rb +321 -0
  97. data/lib/canopus/terminal/pty.rb +103 -0
  98. data/lib/canopus/terminal/scrollback.rb +38 -0
  99. data/lib/canopus/terminal/vt.rb +398 -0
  100. data/lib/canopus/terminal.rb +13 -0
  101. data/lib/canopus/theme.rb +43 -0
  102. data/lib/canopus/version.rb +5 -0
  103. data/lib/canopus/vim/commandable.rb +148 -0
  104. data/lib/canopus/vim/motionable.rb +321 -0
  105. data/lib/canopus/vim/operator_capable.rb +377 -0
  106. data/lib/canopus/vim/text_object_selectable.rb +141 -0
  107. data/lib/canopus/vim.rb +426 -0
  108. data/lib/canopus/workspace/edit/executable.rb +139 -0
  109. data/lib/canopus/workspace/edit/node.rb +8 -0
  110. data/lib/canopus/workspace/edit/plan.rb +170 -0
  111. data/lib/canopus/workspace/edit/resource_preparable.rb +98 -0
  112. data/lib/canopus/workspace/edit.rb +6 -0
  113. data/lib/canopus/workspace/file_change_aware.rb +40 -0
  114. data/lib/canopus/workspace/file_previewable.rb +31 -0
  115. data/lib/canopus/workspace/git_aware.rb +140 -0
  116. data/lib/canopus/workspace/language_aware.rb +413 -0
  117. data/lib/canopus/workspace/language_server_configurable.rb +181 -0
  118. data/lib/canopus/workspace/project_searchable.rb +208 -0
  119. data/lib/canopus/workspace/project_tree_editable.rb +82 -0
  120. data/lib/canopus/workspace/session_persistable.rb +153 -0
  121. data/lib/canopus/workspace/settings_aware.rb +91 -0
  122. data/lib/canopus/workspace/view/terminal_presentable.rb +185 -0
  123. data/lib/canopus/workspace/view.rb +643 -0
  124. data/lib/canopus/workspace.rb +525 -0
  125. data/lib/canopus/wrap_map.rb +108 -0
  126. data/lib/canopus.rb +24 -0
  127. data/sig/canopus.rbs +375 -0
  128. data/sig/controller.rbs +55 -0
  129. data/sig/display_stages.rbs +49 -0
  130. data/sig/git.rbs +140 -0
  131. data/sig/lsp.rbs +99 -0
  132. data/sig/markdown.rbs +11 -0
  133. data/sig/performance_recorder.rbs +23 -0
  134. data/sig/search_services.rbs +8 -0
  135. data/sig/services.rbs +81 -0
  136. data/sig/snippet.rbs +42 -0
  137. data/sig/terminal.rbs +125 -0
  138. data/sig/vim.rbs +27 -0
  139. data/sig/workspace_edits.rbs +11 -0
  140. data/sig/workspace_services.rbs +117 -0
  141. data/tools/certify_snippet_regex.rb +35 -0
  142. data/tools/check_dependencies.rb +80 -0
  143. data/tools/native_check.rb +70 -0
  144. data/tools/package.rb +113 -0
  145. data/tools/package_test.rb +32 -0
  146. 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,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Canopus::Project::SearchWorker::Cancelled < StandardError; end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ Project = Class.new
5
+ end
6
+
7
+ require_relative "../search_worker"
8
+
9
+ Canopus::Project.const_get(:SearchWorker, false).run
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ class SaveConflict < Error; end
5
+ 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