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,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Git
5
+ class Repository
6
+ attr_reader :root, :git_dir, :common_dir, :odb
7
+
8
+ def initialize(path)
9
+ directory = File.expand_path(path)
10
+ directory = File.dirname(directory) unless File.directory?(directory)
11
+ loop do
12
+ location = File.join(directory, ".git")
13
+ if File.directory?(location)
14
+ @root, @git_dir = directory, location
15
+ break
16
+ elsif File.file?(location)
17
+ value = File.read(location).strip
18
+ raise ArgumentError, "invalid gitdir file" unless value.start_with?("gitdir: ")
19
+ @root, @git_dir = directory, File.expand_path(value.delete_prefix("gitdir: "), directory)
20
+ break
21
+ elsif File.file?(File.join(directory, "HEAD")) && File.directory?(File.join(directory, "objects"))
22
+ @root, @git_dir = nil, directory
23
+ break
24
+ end
25
+ parent = File.dirname(directory)
26
+ raise ArgumentError, "not a Git repository: #{path}" if parent == directory
27
+ directory = parent
28
+ end
29
+ common = File.join(git_dir, "commondir")
30
+ @common_dir = File.file?(common) ? File.expand_path(File.read(common).strip, git_dir) : git_dir
31
+ config = File.join(common_dir, "config")
32
+ raise ArgumentError, "SHA-256 repositories are not supported" if File.file?(config) && File.read(config).match?(/objectformat\s*=\s*sha256/i)
33
+ @odb = ObjectDatabase.new(File.join(common_dir, "objects"))
34
+ end
35
+
36
+ def object(oid) = odb.read(resolve(oid) || oid)
37
+ def index = Index.new(File.join(git_dir, "index"))
38
+ def head = resolve("HEAD")
39
+
40
+ def branch
41
+ text = File.read(File.join(git_dir, "HEAD")).strip
42
+ text.start_with?("ref: refs/heads/") ? text.delete_prefix("ref: refs/heads/") : nil
43
+ end
44
+
45
+ def refs
46
+ result = {}
47
+ packed = File.join(common_dir, "packed-refs")
48
+ if File.file?(packed)
49
+ File.foreach(packed) do |line|
50
+ next if line.start_with?("#", "^")
51
+ oid, name = line.strip.split(" ", 2)
52
+ result[name] = oid if name && /\A[0-9a-f]{40}\z/.match?(oid)
53
+ end
54
+ end
55
+ Dir[File.join(common_dir, "refs", "**", "*")].sort.each do |path|
56
+ next unless File.file?(path)
57
+ name = path.delete_prefix(common_dir + "/")
58
+ result[name] = resolve(name)
59
+ end
60
+ result
61
+ end
62
+
63
+ def branches = refs.keys.grep(%r{\Arefs/heads/}).map { |ref| ref.delete_prefix("refs/heads/") }.sort
64
+
65
+ def resolve(name, seen = [])
66
+ return name if /\A[0-9a-f]{40}\z/.match?(name.to_s)
67
+ raise ArgumentError, "invalid Git reference" unless name.is_a?(String) && !name.empty? && !name.start_with?("/") && !name.split("/").any? { |piece| piece == ".." || piece.empty? } && !name.match?(/[\x00-\x20\\]/)
68
+ raise CorruptObject, "cyclic symbolic reference" if seen.include?(name) || seen.length > 32
69
+ candidates = name == "HEAD" || name.start_with?("refs/") ? [name] : ["refs/heads/#{name}", "refs/tags/#{name}", "refs/remotes/#{name}"]
70
+ candidates.each do |candidate|
71
+ directory = candidate == "HEAD" ? git_dir : common_dir
72
+ path = File.join(directory, candidate)
73
+ if File.file?(path)
74
+ value = File.read(path).strip
75
+ return resolve(value.delete_prefix("ref: "), seen + [name]) if value.start_with?("ref: ")
76
+ raise CorruptObject, "invalid reference #{candidate}" unless value.match?(/\A[0-9a-f]{40}\z/)
77
+ return value
78
+ end
79
+ packed = File.join(common_dir, "packed-refs")
80
+ next unless File.file?(packed)
81
+ File.foreach(packed) do |line|
82
+ oid, reference = line.strip.split(" ", 2)
83
+ return oid if reference == candidate && oid.match?(/\A[0-9a-f]{40}\z/)
84
+ end
85
+ end
86
+ nil
87
+ end
88
+
89
+ def commit(reference = "HEAD")
90
+ oid = resolve(reference)
91
+ return nil unless oid
92
+ seen = []
93
+ loop do
94
+ type, data = odb.read(oid)
95
+ if type == "tag"
96
+ raise CorruptObject, "cyclic annotated tag" if seen.include?(oid)
97
+ seen << oid
98
+ oid = data.lines.find { |line| line.start_with?("object ") }&.split&.last
99
+ next
100
+ end
101
+ raise ArgumentError, "reference is not a commit" unless type == "commit"
102
+ headers, message = data.split("\n\n", 2)
103
+ values = headers.lines.reject { |line| line.start_with?(" ") }.map { |line| line.chomp.split(" ", 2) }
104
+ return Commit.new(oid: oid, tree: values.assoc("tree")&.last,
105
+ parents: values.select { |key, _| key == "parent" }.map(&:last),
106
+ author: values.assoc("author")&.last, committer: values.assoc("committer")&.last,
107
+ message: message.to_s.force_encoding(Encoding::UTF_8))
108
+ end
109
+ end
110
+
111
+ def tree(reference = "HEAD")
112
+ revision = commit(reference)
113
+ revision ? read_tree(revision.tree) : {}
114
+ end
115
+
116
+ def blob(path, reference: "HEAD")
117
+ entry = tree(reference)[path]
118
+ entry && entry.mode != 0o160000 ? odb.read(entry.oid).last : nil
119
+ end
120
+
121
+ def status = Status.new(self).call
122
+ def blame(path, reference: "HEAD") = Blame.new(self).call(path, reference: reference)
123
+
124
+ def diff(path, staged: false, context: 3)
125
+ entry = index[path]
126
+ staged_content = entry ? odb.read(entry.oid).last : ""
127
+ before = staged ? blob(path).to_s : staged_content
128
+ after = staged ? staged_content : worktree_content(path).to_s
129
+ Diff.hunks(before, after, context: context)
130
+ end
131
+
132
+ def worktree_content(path)
133
+ absolute = worktree_path(path)
134
+ return File.readlink(absolute).b if File.symlink?(absolute)
135
+ File.file?(absolute) ? File.binread(absolute) : nil
136
+ end
137
+
138
+ def worktree_path(path, replacing: [])
139
+ raise ArgumentError, "bare repository has no worktree" unless root
140
+ raise ArgumentError, "unsafe worktree path" if path.empty? || path.start_with?("/") || path.split("/").any? { |part| ["..", ".git", ""].include?(part) }
141
+ absolute = File.expand_path(path, root)
142
+ raise ArgumentError, "path outside worktree" unless absolute.start_with?(root + "/")
143
+ parent = File.dirname(absolute)
144
+ while parent != root
145
+ raise ArgumentError, "worktree parent is a symlink" if File.symlink?(parent) && !replacing.include?(parent.delete_prefix(root + "/"))
146
+ parent = File.dirname(parent)
147
+ end
148
+ absolute
149
+ end
150
+
151
+ def revert_hunk(path, hunk)
152
+ absolute = worktree_path(path)
153
+ raise ArgumentError, "cannot revert a symlink hunk" if File.symlink?(absolute)
154
+ content = File.binread(absolute)
155
+ updated = Diff.revert(content, hunk)
156
+ atomic_write(absolute, updated, File.stat(absolute).mode & 0o777)
157
+ updated
158
+ end
159
+
160
+ # Checkout is deliberately limited to a clean index and tracked worktree.
161
+ # Untracked files are retained and any collision aborts before the first write.
162
+ def checkout(name)
163
+ raise ArgumentError, "unknown branch: #{name}" unless branches.include?(name)
164
+ raise ArgumentError, "worktree/index must be clean" if status.any? { |entry| entry.index != "?" }
165
+ target = tree("refs/heads/#{name}")
166
+ current = index.entries.to_h { |entry| [entry.path, entry] }
167
+ raise ArgumentError, "submodule checkout requires separate worktree handling" if (target.values + current.values).any? { |entry| entry.mode == 0o160000 }
168
+ removed = current.keys - target.keys
169
+ target.each_key do |path|
170
+ absolute = worktree_path(path, replacing: removed)
171
+ unless current.key?(path)
172
+ if File.directory?(absolute) && !File.symlink?(absolute)
173
+ Find.find(absolute) do |entry|
174
+ next if File.directory?(entry) && !File.symlink?(entry)
175
+ raise ArgumentError, "untracked checkout collision: #{path}" unless removed.include?(entry.delete_prefix(root + "/"))
176
+ end
177
+ elsif File.exist?(absolute) || File.symlink?(absolute)
178
+ raise ArgumentError, "untracked checkout collision: #{path}"
179
+ end
180
+ end
181
+ parent = File.dirname(absolute)
182
+ until parent == root
183
+ if (File.file?(parent) || File.symlink?(parent)) && !removed.include?(parent.delete_prefix(root + "/"))
184
+ raise ArgumentError, "untracked checkout collision: #{parent}"
185
+ end
186
+ parent = File.dirname(parent)
187
+ end
188
+ end
189
+ contents = target.to_h { |path, entry| [path, odb.read(entry.oid).last] }
190
+ backups = current.to_h { |path, entry| [path, [worktree_content(path), entry.mode]] }
191
+ backups.each do |path, (data, _)|
192
+ raise ArgumentError, "worktree changed before checkout: #{path}" unless data && ObjectDatabase.hash("blob", data) == current[path].oid
193
+ end
194
+ index_path = File.join(git_dir, "index")
195
+ head_path = File.join(git_dir, "HEAD")
196
+ old_index = File.binread(index_path) if File.exist?(index_path)
197
+ old_head = File.binread(head_path)
198
+ locks = []
199
+ changed = false
200
+ begin
201
+ [index_path, head_path].each { |path| locks << File.open(path + ".lock", File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o644) }
202
+ changed = true
203
+ removed.sort_by { |path| -path.count("/") }.each { |path| File.unlink(worktree_path(path)) }
204
+ prune_empty_directories(removed)
205
+ target.each do |path, entry|
206
+ absolute = worktree_path(path)
207
+ Dir.rmdir(absolute) if File.directory?(absolute) && !File.symlink?(absolute)
208
+ FileUtils.mkdir_p(File.dirname(absolute))
209
+ File.unlink(absolute) if File.symlink?(absolute)
210
+ if entry.mode == 0o120000
211
+ File.unlink(absolute) if File.exist?(absolute)
212
+ File.symlink(contents[path], absolute)
213
+ else
214
+ atomic_write(absolute, contents[path], entry.mode & 0o777)
215
+ end
216
+ end
217
+ entries = target.map do |path, entry|
218
+ stat = File.lstat(worktree_path(path))
219
+ Index::Entry.new(path: path, oid: entry.oid, mode: entry.mode, size: stat.size,
220
+ mtime: stat.mtime.to_i, mtime_nsec: stat.mtime.nsec, ctime: stat.ctime.to_i, ctime_nsec: stat.ctime.nsec,
221
+ dev: stat.dev, ino: stat.ino, uid: stat.uid, gid: stat.gid, stage: 0)
222
+ end
223
+ locks[0].write(Index.encode(entries))
224
+ locks[1].write("ref: refs/heads/#{name}\n")
225
+ locks.each { |file| file.flush; file.fsync; file.close }
226
+ File.rename(index_path + ".lock", index_path)
227
+ File.rename(head_path + ".lock", head_path)
228
+ rescue StandardError
229
+ if changed
230
+ (target.keys - current.keys).each do |path|
231
+ absolute = worktree_path(path)
232
+ File.unlink(absolute) if File.file?(absolute) || File.symlink?(absolute)
233
+ end
234
+ prune_empty_directories(target.keys - current.keys)
235
+ backups.each do |path, (data, mode)|
236
+ absolute = worktree_path(path)
237
+ Dir.rmdir(absolute) if File.directory?(absolute) && !File.symlink?(absolute)
238
+ FileUtils.mkdir_p(File.dirname(absolute))
239
+ File.unlink(absolute) if File.symlink?(absolute)
240
+ if mode == 0o120000
241
+ File.unlink(absolute) if File.exist?(absolute)
242
+ File.symlink(data, absolute)
243
+ else
244
+ atomic_write(absolute, data, mode & 0o777)
245
+ end
246
+ end
247
+ old_index ? atomic_write(index_path, old_index, 0o644) : File.unlink(index_path) if old_index || File.exist?(index_path)
248
+ atomic_write(head_path, old_head, 0o644)
249
+ end
250
+ raise
251
+ ensure
252
+ locks.each do |file|
253
+ file.close unless file.closed?
254
+ File.unlink(file.path) if File.exist?(file.path)
255
+ end
256
+ end
257
+ name
258
+ end
259
+
260
+ private
261
+
262
+ def prune_empty_directories(paths)
263
+ paths.sort_by { |path| -path.count("/") }.each do |path|
264
+ parent = File.dirname(File.join(root, path))
265
+ until parent == root
266
+ begin
267
+ Dir.rmdir(parent)
268
+ rescue Errno::ENOTEMPTY, Errno::EEXIST, Errno::ENOENT, Errno::ENOTDIR
269
+ break
270
+ end
271
+ parent = File.dirname(parent)
272
+ end
273
+ end
274
+ end
275
+
276
+ def atomic_write(path, data, mode)
277
+ Tempfile.create([".canopus-", ".tmp"], File.dirname(path)) do |file|
278
+ file.binmode
279
+ file.chmod(mode)
280
+ file.write(data)
281
+ file.flush
282
+ file.fsync
283
+ file.close
284
+ File.rename(file.path, path)
285
+ end
286
+ end
287
+
288
+ def read_tree(oid, prefix = "", stack = [])
289
+ raise CorruptObject, "tree nesting exceeds limit" if stack.length > 256 || stack.include?(oid)
290
+ type, data = odb.read(oid)
291
+ raise CorruptObject, "expected tree object" unless type == "tree"
292
+ result = {}
293
+ offset = 0
294
+ while offset < data.bytesize
295
+ ending = data.index("\0", offset)
296
+ raise CorruptObject, "truncated tree entry" unless ending && ending + 21 <= data.bytesize
297
+ mode, name = data[offset...ending].split(" ", 2)
298
+ raise CorruptObject, "unsafe tree entry" unless mode&.match?(/\A[0-7]+\z/) && name && !["", ".", "..", ".git"].include?(name) && !name.include?("/")
299
+ path = (prefix + name).force_encoding(Encoding::UTF_8)
300
+ object = data[ending + 1, 20].unpack1("H*")
301
+ mode = mode.to_i(8)
302
+ if mode == 0o40000
303
+ result.merge!(read_tree(object, path + "/", stack + [oid]))
304
+ else
305
+ result[path] = TreeEntry.new(path: path, oid: object, mode: mode)
306
+ end
307
+ offset = ending + 21
308
+ end
309
+ result
310
+ end
311
+ end
312
+ end
313
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Git
5
+ class Status
6
+ Entry = Struct.new(:path, :index, :worktree, keyword_init: true) do
7
+ def code = index + worktree
8
+ end
9
+
10
+ def initialize(repository)
11
+ @repository = repository
12
+ end
13
+
14
+ def call
15
+ repository = @repository
16
+ index = repository.index
17
+ head = repository.tree
18
+ tracked = index.entries.group_by(&:path)
19
+ result = []
20
+ (head.keys | tracked.keys).sort.each do |path|
21
+ entries = tracked[path]
22
+ if entries&.any? { |entry| entry.stage != 0 }
23
+ stages = entries.map(&:stage)
24
+ code = {[1] => "DD", [2] => "AU", [3] => "UA", [1, 2] => "UD", [1, 3] => "DU", [2, 3] => "AA", [1, 2, 3] => "UU"}.fetch(stages.sort, "UU")
25
+ result << Entry.new(path: path, index: code[0], worktree: code[1])
26
+ next
27
+ end
28
+ staged = entries&.first
29
+ original = head[path]
30
+ x = if !original then "A"
31
+ elsif !staged then "D"
32
+ elsif original.mode != staged.mode || original.oid != staged.oid then "M"
33
+ else " " end
34
+ y = staged ? worktree_status(staged, index.path) : " "
35
+ result << Entry.new(path: path, index: x, worktree: y) unless x == " " && y == " "
36
+ end
37
+ submodules = index.entries.select { |entry| entry.mode == 0o160000 }.map { |entry| entry.path + "/" }
38
+ Project.new(repository.root).files(include_symlinks: true).each do |path|
39
+ next if submodules.any? { |prefix| path.start_with?(prefix) }
40
+ result << Entry.new(path: path, index: "?", worktree: "?") unless tracked.key?(path)
41
+ end
42
+ result.sort_by(&:path)
43
+ end
44
+
45
+ private
46
+
47
+ def worktree_status(entry, index_path)
48
+ absolute = @repository.worktree_path(entry.path)
49
+ stat = File.lstat(absolute)
50
+ if entry.mode == 0o160000
51
+ return "T" unless stat.directory?
52
+ begin
53
+ return Repository.new(absolute).head == entry.oid ? " " : "M"
54
+ rescue ArgumentError
55
+ return "M"
56
+ end
57
+ end
58
+ mode = stat.symlink? ? 0o120000 : stat.file? ? 0o100000 | ((stat.mode & 0o100).positive? ? 0o755 : 0o644) : 0
59
+ return "T" if (mode & 0o170000) != (entry.mode & 0o170000)
60
+ return "M" if mode != entry.mode
61
+ return " " if (entry.extended_flags.to_i & 0x4000).positive? # skip-worktree
62
+ index_time = File.mtime(index_path)
63
+ if stat.size == entry.size && stat.mtime.to_i == entry.mtime && stat.mtime.nsec == entry.mtime_nsec &&
64
+ stat.ctime.to_i == entry.ctime && stat.ctime.nsec == entry.ctime_nsec && stat.mtime.to_i < index_time.to_i
65
+ return " "
66
+ end
67
+ content = stat.symlink? ? File.readlink(absolute).b : File.binread(absolute)
68
+ ObjectDatabase.hash("blob", content) == entry.oid ? " " : "M"
69
+ rescue Errno::ENOENT, Errno::ENOTDIR
70
+ (entry.extended_flags.to_i & 0x4000).positive? ? " " : "D"
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Git
5
+ TreeEntry = Struct.new(:path, :oid, :mode, keyword_init: true)
6
+ end
7
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "find"
5
+ require "tempfile"
6
+ require_relative "project"
7
+ require_relative "git/object_database"
8
+ require_relative "git/index"
9
+ require_relative "git/diff"
10
+ require_relative "git/status"
11
+ require_relative "git/blame"
12
+ require_relative "git/commit"
13
+ require_relative "git/tree_entry"
14
+
15
+ require_relative "git/repository"
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ # Theme JSON maps file/directory/expanded_directory and extensions to local SVGs.
5
+ class IconTheme
6
+ FILE = '<svg viewBox="0 0 16 16"><path d="M3 1h6l4 4v10H3z M9 1v4h4" fill="none" stroke="currentColor" stroke-linejoin="round"/></svg>'
7
+ FOLDER = '<svg viewBox="0 0 16 16"><path d="M1 4V2h5l2 2h7v10H1z" fill="none" stroke="currentColor" stroke-linejoin="round"/></svg>'
8
+ def initialize(path = nil)
9
+ @icons, @extensions = {}, {}
10
+ if path
11
+ raise Error, "icon theme exceeds 256 KiB" if File.size(path) > 262_144
12
+ document = Kochab.parse(File.read(path))
13
+ raise Error, "invalid icon theme" unless document.valid? && document.value.is_a?(Hash)
14
+ @root, @definition = File.realpath(File.dirname(path)), document.value
15
+ @extensions = @definition.fetch("extensions", {})
16
+ raise Error, "icon extensions must be a map with at most 256 entries" unless @extensions.is_a?(Hash) && @extensions.length <= 256
17
+ paths = @definition.values_at("file", "directory", "expanded_directory").compact + @extensions.values
18
+ paths.uniq.each { |value| load_icon(value) }
19
+ else
20
+ @definition = {}
21
+ end
22
+ @icons[:file] = Zaniah::SVG.new(FILE)
23
+ @icons[:directory] = Zaniah::SVG.new(FOLDER)
24
+ end
25
+ def texture(path, directory: false, expanded: false, size: 16, scale: 1, color: "#999")
26
+ key = if directory
27
+ @definition[expanded ? "expanded_directory" : "directory"] || @definition["directory"] || :directory
28
+ else
29
+ @extensions[File.extname(path)] || @extensions[File.basename(path)] || @definition["file"] || :file
30
+ end
31
+ pixels = (size * scale).ceil
32
+ @icons.fetch(key).texture(width: pixels, height: pixels, color: color)
33
+ end
34
+ private
35
+ def load_icon(relative)
36
+ raise Error, "icon filename must be a string" unless relative.is_a?(String)
37
+ full = File.realpath(File.expand_path(relative, @root))
38
+ raise Error, "icon must remain inside the theme directory" unless full.start_with?(@root + File::SEPARATOR)
39
+ raise Error, "icon exceeds 2 MiB" if File.size(full) > 2_097_152
40
+ @icons[relative] = Zaniah::SVG.open(full)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Canopus::Language::BackgroundAnalysis::Job
4
+ attr_accessor :future, :inner
5
+ def cancel
6
+ @cancelled = true
7
+ @inner&.cancel
8
+ end
9
+ def check!
10
+ raise Zaniah::Task::Cancelled, "superseded language analysis" if @cancelled
11
+ end
12
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Two preparation threads and two named-handler processes are shared by
4
+ # all documents. No unbounded per-document threads or queued snapshots.
5
+ class Canopus::Language::BackgroundAnalysis::Scheduler
6
+ BackgroundAnalysis = Canopus::Language.const_get(:BackgroundAnalysis, false)
7
+ private_constant :BackgroundAnalysis
8
+
9
+ @lock = Mutex.new
10
+ class << self
11
+ def acquire
12
+ @lock.synchronize do
13
+ @shared ||= new
14
+ @owners = (@owners || 0) + 1
15
+ @shared
16
+ end
17
+ end
18
+ def release(scheduler)
19
+ close = @lock.synchronize do
20
+ next false unless @shared.equal?(scheduler)
21
+ @owners -= 1
22
+ if @owners.zero?
23
+ @shared = nil
24
+ true
25
+ end
26
+ end
27
+ scheduler.shutdown if close
28
+ end
29
+ end
30
+
31
+ def initialize
32
+ @executor = Zaniah::TaskExecutor.new(workers: 2)
33
+ @lock, @pool_lock, @jobs = Mutex.new, Mutex.new, []
34
+ end
35
+ def submit(snapshot, prior_syntax: nil)
36
+ job = @lock.synchronize do
37
+ return if @closed || @jobs.length >= 2
38
+ BackgroundAnalysis::Job.new.tap { |value| @jobs << value }
39
+ end
40
+ job.future = @executor.background do
41
+ begin
42
+ job.check!
43
+ payload = BackgroundAnalysis.prepare(snapshot, job)
44
+ job.check!
45
+ pool = @pool_lock.synchronize do
46
+ job.check!
47
+ @pool ||= Zaniah::ProcessPool.new(workers: 2,
48
+ handler: "Canopus::Language::SyntaxWorker",
49
+ requires: [File.expand_path("../syntax_worker.rb", __dir__)],
50
+ load_paths: [File.expand_path("..", __dir__)], max_pending: 4, max_bytes: 16 << 20)
51
+ end
52
+ job.inner = pool.submit(payload)
53
+ job.check!
54
+ response = job.inner.await(timeout: 5)
55
+ job.check!
56
+ BackgroundAnalysis.decode(response, prior_syntax: prior_syntax)
57
+ ensure
58
+ job.inner&.cancel unless job.inner&.done?
59
+ @lock.synchronize { @jobs.delete(job) }
60
+ end
61
+ end
62
+ job
63
+ end
64
+ def shutdown
65
+ @lock.synchronize { @closed = true; @jobs.each(&:cancel) }
66
+ @pool_lock.synchronize { @pool&.shutdown }
67
+ ensure
68
+ @executor.shutdown
69
+ end
70
+ end