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,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
|
data/lib/canopus/git.rb
ADDED
|
@@ -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
|