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,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Canopus
6
+ module Editor::SnippetExpandable
7
+ SnippetField = Struct.new(:first, :last, :range, :parents, :index, :order)
8
+ private_constant :SnippetField
9
+
10
+ def snippet_variables(workspace_root: nil, clipboard: nil, now: Time.now, selection: primary, cursor_index: @selections.index(selection) || 0)
11
+ point = @buffer.rope.point_at(selection.start)
12
+ line = @buffer.line(point.row).delete_suffix("\n").delete_suffix("\r")
13
+ column = @buffer.rope.byteslice(@buffer.rope.line_start(point.row), selection.start - @buffer.rope.line_start(point.row)).to_s.length
14
+ word = line[0...column][/\w*\z/] + line[column..][/\A\w*/]
15
+ path = @buffer.path
16
+ root = workspace_root && File.expand_path(workspace_root)
17
+ comments = case language_document.definition.name
18
+ when "ruby" then ["=begin", "=end"]
19
+ when "html", "markdown" then ["<!--", "-->"]
20
+ when "javascript", "typescript", "rust", "go", "c", "cpp", "json", "css" then ["/*", "*/"]
21
+ else ["", ""]
22
+ end
23
+ variables = {
24
+ "TM_SELECTED_TEXT" => @buffer.rope.byteslice(selection.range).to_s,
25
+ "TM_CURRENT_LINE" => line, "TM_CURRENT_WORD" => word,
26
+ "TM_LINE_INDEX" => point.row.to_s, "TM_LINE_NUMBER" => (point.row + 1).to_s,
27
+ "TM_FILENAME" => path && File.basename(path), "TM_FILENAME_BASE" => path && File.basename(path, File.extname(path)),
28
+ "TM_DIRECTORY" => path && File.dirname(path), "TM_FILEPATH" => path,
29
+ "RELATIVE_FILEPATH" => path && (root ? snippet_relative_path(path, root) : File.basename(path)),
30
+ "CLIPBOARD" => clipboard, "WORKSPACE_NAME" => root && File.basename(root), "WORKSPACE_FOLDER" => root,
31
+ "CURSOR_INDEX" => cursor_index.to_s, "CURSOR_NUMBER" => (cursor_index + 1).to_s,
32
+ "CURRENT_MILLISECOND" => (now.nsec / 1_000_000).to_s.rjust(3, "0"),
33
+ "CURRENT_SECONDS_UNIX" => now.to_i.to_s, "CURRENT_MILLISECONDS_UNIX" => (now.to_r * 1000).floor.to_s,
34
+ "CURRENT_TIMEZONE_OFFSET" => now.strftime("%:z"),
35
+ "CURRENT_TIMEZONE_NAME" => ENV["TZ"]&.match?(/\A(?:UTC|[A-Za-z_]+\/[A-Za-z_\/+-]+)\z/) ? ENV["TZ"] : nil,
36
+ "RANDOM" => SecureRandom.random_number(1_000_000).to_s.rjust(6, "0"), "RANDOM_HEX" => SecureRandom.hex(3), "UUID" => SecureRandom.uuid,
37
+ "BLOCK_COMMENT_START" => comments[0], "BLOCK_COMMENT_END" => comments[1],
38
+ "LINE_COMMENT" => language_document.definition.comment == comments[0] ? "" : language_document.definition.comment
39
+ }
40
+ {"YEAR" => "%Y", "YEAR_SHORT" => "%y", "MONTH" => "%m", "MONTH_NAME" => "%B", "MONTH_NAME_SHORT" => "%b",
41
+ "DATE" => "%d", "DAY_NAME" => "%A", "DAY_NAME_SHORT" => "%a", "HOUR" => "%H", "MINUTE" => "%M", "SECOND" => "%S"}.each do |name, format|
42
+ variables["CURRENT_#{name}"] = now.strftime(format)
43
+ end
44
+ variables
45
+ end
46
+
47
+ def insert_snippet(source, variables: {}, workspace_root: nil, clipboard: nil)
48
+ raise Error, "snippet variables must be a Hash" unless variables.is_a?(Hash)
49
+ # Parse every cursor expansion before changing text or disposing a live session.
50
+ snippets = @selections.each_with_index.map do |selection, index|
51
+ builtins = snippet_variables(workspace_root: workspace_root, clipboard: clipboard, selection: selection, cursor_index: index)
52
+ Snippet.new(source, variables: builtins.merge(variables.transform_keys(&:to_s)))
53
+ end
54
+ starts, delta = [], 0
55
+ @selections.zip(snippets).each do |selection, snippet|
56
+ starts << selection.start + delta
57
+ delta += snippet.text.bytesize - selection.range.size
58
+ end
59
+ replace_selections(snippets.map(&:text), kind: :snippet)
60
+ clear_snippet
61
+ @snippet_instances = snippets.zip(starts).map do |snippet, start|
62
+ fields, mirrors = {}, []
63
+ snippet.occurrences.each do |occurrence|
64
+ field = snippet_field(start + occurrence.range.begin...start + occurrence.range.end, occurrence.parents, occurrence.index)
65
+ if occurrence.transform
66
+ mirrors << {index: occurrence.index, field: field, transform: occurrence.transform}
67
+ else
68
+ (fields[occurrence.index] ||= []) << field
69
+ end
70
+ end
71
+ mirrors.each { |mirror| fields[mirror[:index]] ||= [mirror[:field]] }
72
+ fields[0] ||= [snippet_field(start + snippet.text.bytesize...start + snippet.text.bytesize)]
73
+ {snippet: snippet, fields: fields, mirrors: mirrors}
74
+ end
75
+ @snippet_order = @snippet_instances.flat_map { |instance| instance[:fields].keys }.uniq.sort_by { |index| index.zero? ? Float::INFINITY : index }
76
+ @snippet_position = -1
77
+ @snippet_subscription = @buffer.on_edit { |patch| snippet_edited(patch) }
78
+ next_snippet
79
+ snippets.last
80
+ end
81
+
82
+ def snippet_active? = !@snippet_instances.nil?
83
+
84
+ def snippet_choices
85
+ return nil unless snippet_active?
86
+ @snippet_instances.lazy.map { |instance| instance[:snippet].choices[@snippet_order[@snippet_position]] }.find(&:itself)
87
+ end
88
+
89
+ def choose_snippet(value)
90
+ choices = snippet_choices
91
+ return false unless choices
92
+ value = choices[value] if value.is_a?(Integer) && value >= 0
93
+ return false unless choices.include?(value)
94
+ snippet_select(@snippet_order[@snippet_position])
95
+ replace_selections(value, kind: :snippet_choice)
96
+ true
97
+ end
98
+
99
+ def next_snippet = snippet_move(1)
100
+ def previous_snippet = snippet_move(-1)
101
+
102
+ def clear_snippet
103
+ active = @snippet_instances
104
+ @snippet_subscription&.detach
105
+ @snippet_subscription = nil
106
+ snippet_fields.each { |field| snippet_release(field) } if @snippet_instances
107
+ @snippet_instances = @snippet_order = @snippet_position = nil
108
+ set_selections(@selections) if active
109
+ nil
110
+ end
111
+
112
+ private
113
+
114
+ def snippet_relative_path(path, root)
115
+ file_parts, root_parts = path.split(File::SEPARATOR), root.split(File::SEPARATOR)
116
+ return path if File::ALT_SEPARATOR && file_parts.first != root_parts.first
117
+ while !file_parts.empty? && file_parts.first == root_parts.first
118
+ file_parts.shift
119
+ root_parts.shift
120
+ end
121
+ ([".."] * root_parts.length + file_parts).join(File::SEPARATOR)
122
+ end
123
+
124
+ def snippet_field(range, parents = [], index = 0)
125
+ @snippet_field_order = (@snippet_field_order || 0) + 1
126
+ SnippetField.new(@buffer.anchor(range.begin, bias: :right), @buffer.anchor(range.end, bias: range.size.zero? ? :right : :left), range, parents, index, @snippet_field_order)
127
+ end
128
+
129
+ def snippet_fields
130
+ @snippet_instances.flat_map { |instance| instance[:fields].values.flatten + instance[:mirrors].map { |mirror| mirror[:field] } }.uniq(&:object_id)
131
+ end
132
+
133
+ def snippet_release(field)
134
+ @buffer.release_anchor(field.first)
135
+ @buffer.release_anchor(field.last)
136
+ end
137
+
138
+ def snippet_range(field) = @buffer.resolve(field.first)...@buffer.resolve(field.last)
139
+
140
+ def snippet_move(direction)
141
+ return false unless snippet_active?
142
+ position = @snippet_position + direction
143
+ return false if position < 0 || position >= @snippet_order.length
144
+ snippet_transform if @snippet_position >= 0
145
+ while position >= 0 && position < @snippet_order.length
146
+ index = @snippet_order[position]
147
+ if @snippet_instances.any? { |instance| instance[:fields][index]&.any? }
148
+ @snippet_position = position
149
+ snippet_select(index)
150
+ clear_snippet if index.zero?
151
+ reveal_cursor
152
+ return true
153
+ end
154
+ position += direction
155
+ end
156
+ false
157
+ end
158
+
159
+ def snippet_select(index)
160
+ active = @snippet_instances.flat_map { |instance| instance[:fields][index] || [] }
161
+ active_ids = active.to_h { |field| [field.object_id, true] }
162
+ ancestors = active.flat_map(&:parents).to_h { |number| [number, true] }
163
+ # Only the active placeholders and their enclosing placeholders grow at
164
+ # both edges. Adjacent/empty future fields must move past newly typed text.
165
+ snippet_fields.each do |field|
166
+ range = snippet_range(field)
167
+ enclosing = active_ids[field.object_id] || (ancestors[field.index] && active.any? { |other| other.parents.include?(field.index) && range.begin <= other.range.begin && range.end >= other.range.end })
168
+ snippet_release(field)
169
+ field.first = @buffer.anchor(range.begin, bias: enclosing ? :left : :right)
170
+ field.last = @buffer.anchor(range.end, bias: enclosing || range.size.zero? ? :right : :left)
171
+ field.range = range
172
+ end
173
+ set_selections(active.map do |field|
174
+ range = snippet_range(field)
175
+ selection = Selection.new(@next_selection, range.begin, range.end, nil)
176
+ @next_selection += 1
177
+ selection
178
+ end)
179
+ end
180
+
181
+ def snippet_transform
182
+ index = @snippet_order[@snippet_position]
183
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.25
184
+ replacements = @snippet_instances.flat_map do |instance|
185
+ source = instance[:fields][index]&.first
186
+ next [] unless source
187
+ value = @buffer.rope.byteslice(snippet_range(source)).to_s
188
+ instance[:mirrors].filter_map do |mirror|
189
+ next unless mirror[:index] == index
190
+ range = snippet_range(mirror[:field])
191
+ replacement = mirror[:transform].apply(value)
192
+ raise Error, "snippet transforms exceeded execution limit" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
193
+ [range, replacement, mirror[:field]] unless @buffer.rope.byteslice(range).to_s == replacement
194
+ end
195
+ end.sort_by { |range, _text, _field| range.begin }
196
+ return if replacements.empty?
197
+ @snippet_applying = true
198
+ @snippet_replacements = replacements
199
+ @buffer.edit(replacements.map { |range, text, _field| [range, text] }, kind: :snippet_transform)
200
+ delta = 0
201
+ replacements.each do |range, text, field|
202
+ start = range.begin + delta
203
+ snippet_reanchor(field, start...start + text.bytesize)
204
+ delta += text.bytesize - range.size
205
+ end
206
+ ensure
207
+ @snippet_applying = false
208
+ @snippet_replacements = nil
209
+ end
210
+
211
+ def snippet_reanchor(field, range, first_bias: :left, last_bias: :right)
212
+ snippet_release(field)
213
+ field.first = @buffer.anchor(range.begin, bias: first_bias)
214
+ field.last = @buffer.anchor(range.end, bias: last_bias)
215
+ field.range = range
216
+ end
217
+
218
+ def snippet_map_offset(patch, offset, bias)
219
+ delta = 0
220
+ patch.edits.each do |edit|
221
+ first, last = edit.old_range.begin, edit.old_range.end
222
+ break if offset < first || (offset == first && bias == :left)
223
+ if offset >= last
224
+ delta += edit.new_text.bytesize - edit.old_range.size
225
+ else
226
+ return bias == :left ? edit.new_range.begin : edit.new_range.end
227
+ end
228
+ end
229
+ offset + delta
230
+ end
231
+
232
+ def snippet_edited(patch)
233
+ if patch.is_a?(Patch::Composite) || patch.is_a?(Patch::Reload)
234
+ clear_snippet
235
+ return
236
+ end
237
+ unless @snippet_applying
238
+ index = @snippet_order[@snippet_position]
239
+ edits = patch.edits.dup
240
+ active_fields = @snippet_instances.flat_map { |instance| instance[:fields][index] || [] }
241
+ @snippet_instances.each do |instance|
242
+ active = (instance[:fields][index] || []).map(&:range)
243
+ removed = []
244
+ discard = lambda do |field|
245
+ range = field.range
246
+ field.parents.include?(index) && active.any? { |outer| outer.begin <= range.begin && outer.end >= range.end } &&
247
+ patch.edits.any? { |edit| edit.old_range.size.positive? && edit.old_range.begin <= range.begin && edit.old_range.end >= range.end }
248
+ end
249
+ instance[:fields].each do |number, fields|
250
+ next if number == index || number.zero?
251
+ fields.delete_if { |field| discard.call(field) && (removed << field) }
252
+ end
253
+ instance[:mirrors].delete_if { |mirror| mirror[:index] != index && discard.call(mirror[:field]) && (removed << mirror[:field]) }
254
+ removed.uniq(&:object_id).each { |field| snippet_release(field) }
255
+ end
256
+ # Multiple empty mirrors occupy one byte offset, but each insertion has
257
+ # its own new range. A generic point anchor cannot distinguish them.
258
+ precise = active_fields.sort_by { |field| field.range.begin }.filter_map do |field|
259
+ matching = edits.index { |edit| edit.old_range == field.range }
260
+ [field, edits.delete_at(matching).new_range] if matching
261
+ end
262
+ end
263
+ snippet_fields.each do |field|
264
+ range = field.range
265
+ first_bias, last_bias = field.first.bias, field.last.bias
266
+ if @snippet_applying
267
+ enclosing = @snippet_replacements.any? { |old, _text, changed| changed.parents.include?(field.index) && range.begin <= old.begin && range.end >= old.end }
268
+ first_bias, last_bias = enclosing ? [:left, :right] : [:right, :left]
269
+ if range.size.zero? && !enclosing
270
+ following = @snippet_replacements.any? { |old, _text, changed| old.begin == range.begin && changed.order > field.order }
271
+ first_bias = last_bias = following ? :left : :right
272
+ end
273
+ end
274
+ first = snippet_map_offset(patch, range.begin, first_bias)
275
+ last = snippet_map_offset(patch, range.end, last_bias)
276
+ snippet_reanchor(field, [first, last].min...last, first_bias: field.first.bias, last_bias: field.last.bias)
277
+ end
278
+ precise&.each { |field, range| snippet_reanchor(field, range) }
279
+ end
280
+ end
281
+ end