canopus 0.4.0 → 0.5.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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +191 -19
  4. data/assets/keymaps/emacs.jsonc +5 -0
  5. data/assets/keymaps/jetbrains.jsonc +5 -0
  6. data/assets/keymaps/sublime.jsonc +5 -0
  7. data/assets/keymaps/vscode.jsonc +5 -0
  8. data/docs/debugging.md +143 -0
  9. data/docs/distribution.md +40 -9
  10. data/docs/lsp.md +131 -0
  11. data/docs/performance.md +18 -0
  12. data/docs/providers.md +36 -0
  13. data/docs/release-log.md +12 -0
  14. data/docs/tasks.md +64 -0
  15. data/docs/testing.md +49 -0
  16. data/docs/vim.md +3 -0
  17. data/exe/canopus +4 -1
  18. data/lib/canopus/buffer.rb +291 -50
  19. data/lib/canopus/cli.rb +5 -2
  20. data/lib/canopus/command.rb +13 -4
  21. data/lib/canopus/controller.rb +183 -40
  22. data/lib/canopus/debug/breakpoints.rb +550 -0
  23. data/lib/canopus/debug/configuration.rb +291 -0
  24. data/lib/canopus/debug/console.rb +243 -0
  25. data/lib/canopus/debug/panel.rb +487 -0
  26. data/lib/canopus/debug/session.rb +354 -0
  27. data/lib/canopus/decoration.rb +4 -2
  28. data/lib/canopus/denebola_compat.rb +27 -0
  29. data/lib/canopus/diagnostics.rb +173 -0
  30. data/lib/canopus/display_map/overlay_map.rb +6 -2
  31. data/lib/canopus/editor/snippet_expandable.rb +3 -2
  32. data/lib/canopus/editor.rb +122 -31
  33. data/lib/canopus/git/state.rb +100 -0
  34. data/lib/canopus/language/background_analysis.rb +93 -14
  35. data/lib/canopus/language/document.rb +70 -34
  36. data/lib/canopus/language/symbol.rb +1 -0
  37. data/lib/canopus/language/syntax_worker.rb +61 -9
  38. data/lib/canopus/language.rb +3 -0
  39. data/lib/canopus/minimap.rb +201 -0
  40. data/lib/canopus/patch.rb +3 -1
  41. data/lib/canopus/plugins/api.rb +4 -2
  42. data/lib/canopus/plugins/isolated_runtime.rb +64 -7
  43. data/lib/canopus/plugins/registry.rb +114 -3
  44. data/lib/canopus/plugins.rb +8 -7
  45. data/lib/canopus/project.rb +15 -6
  46. data/lib/canopus/provider.rb +192 -0
  47. data/lib/canopus/regexp_compat.rb +36 -4
  48. data/lib/canopus/selection.rb +12 -0
  49. data/lib/canopus/settings/schema.rb +201 -0
  50. data/lib/canopus/settings.rb +328 -49
  51. data/lib/canopus/task/configuration.rb +356 -0
  52. data/lib/canopus/task/problem_matcher.rb +264 -0
  53. data/lib/canopus/task/runner.rb +314 -0
  54. data/lib/canopus/test_runner/bounded_ignore.rb +144 -0
  55. data/lib/canopus/test_runner/discovery.rb +97 -0
  56. data/lib/canopus/test_runner/execution.rb +194 -0
  57. data/lib/canopus/test_runner/minitest.rb +116 -0
  58. data/lib/canopus/test_runner/rspec.rb +103 -0
  59. data/lib/canopus/test_runner/safe_walker.rb +37 -0
  60. data/lib/canopus/test_runner.rb +54 -0
  61. data/lib/canopus/theme/import.rb +131 -0
  62. data/lib/canopus/theme.rb +28 -4
  63. data/lib/canopus/version.rb +1 -1
  64. data/lib/canopus/vim/motionable.rb +1 -1
  65. data/lib/canopus/vim.rb +10 -16
  66. data/lib/canopus/workspace/auto_savable.rb +83 -0
  67. data/lib/canopus/workspace/debug_aware.rb +600 -0
  68. data/lib/canopus/workspace/edit/plan.rb +2 -2
  69. data/lib/canopus/workspace/editor_configurable.rb +113 -0
  70. data/lib/canopus/workspace/file_change_aware.rb +11 -3
  71. data/lib/canopus/workspace/git_aware.rb +80 -34
  72. data/lib/canopus/workspace/git_blame.rb +175 -0
  73. data/lib/canopus/workspace/git_conflict_resolution.rb +410 -0
  74. data/lib/canopus/workspace/git_diff_view.rb +641 -0
  75. data/lib/canopus/workspace/git_history.rb +171 -0
  76. data/lib/canopus/workspace/git_remote.rb +267 -0
  77. data/lib/canopus/workspace/git_staging.rb +583 -0
  78. data/lib/canopus/workspace/hierarchy_aware.rb +477 -0
  79. data/lib/canopus/workspace/keymap_aware.rb +66 -0
  80. data/lib/canopus/workspace/language_aware.rb +3721 -135
  81. data/lib/canopus/workspace/language_server_configurable.rb +287 -71
  82. data/lib/canopus/workspace/persistent_undo.rb +169 -0
  83. data/lib/canopus/workspace/problems_aware.rb +131 -0
  84. data/lib/canopus/workspace/project_searchable.rb +88 -11
  85. data/lib/canopus/workspace/project_tree_editable.rb +3 -1
  86. data/lib/canopus/workspace/recoverable.rb +204 -0
  87. data/lib/canopus/workspace/session_persistable.rb +85 -13
  88. data/lib/canopus/workspace/settings_aware.rb +76 -2
  89. data/lib/canopus/workspace/task_aware.rb +194 -0
  90. data/lib/canopus/workspace/test_aware.rb +394 -0
  91. data/lib/canopus/workspace/trust.rb +73 -0
  92. data/lib/canopus/workspace/view/terminal_presentable.rb +298 -47
  93. data/lib/canopus/workspace/view.rb +399 -74
  94. data/lib/canopus/workspace.rb +688 -44
  95. data/lib/canopus.rb +20 -2
  96. data/sig/canopus.rbs +138 -31
  97. data/sig/controller.rbs +10 -3
  98. data/sig/debug.rbs +121 -0
  99. data/sig/decoration.rbs +12 -2
  100. data/sig/diagnostics.rbs +26 -0
  101. data/sig/minimap.rbs +20 -0
  102. data/sig/provider.rbs +29 -0
  103. data/sig/services.rbs +1 -16
  104. data/sig/task.rbs +92 -0
  105. data/sig/test_runner.rbs +109 -0
  106. data/sig/trust.rbs +17 -0
  107. data/sig/workspace_edits.rbs +2 -2
  108. data/sig/workspace_services.rbs +234 -2
  109. data/tools/check_dependencies.rb +21 -2
  110. data/tools/package.rb +172 -14
  111. data/tools/package_signature_test.rb +33 -0
  112. data/tools/package_test.rb +29 -0
  113. data/tools/sign_package.rb +123 -0
  114. data/tools/update.rb +189 -0
  115. data/tools/update_test.rb +71 -0
  116. metadata +206 -31
  117. data/lib/canopus/lsp/client.rb +0 -307
  118. data/lib/canopus/lsp/error.rb +0 -7
  119. data/lib/canopus/lsp/future/subscription.rb +0 -9
  120. data/lib/canopus/lsp/future.rb +0 -87
  121. data/lib/canopus/lsp/protocol.rb +0 -83
  122. data/lib/canopus/lsp/server_error.rb +0 -13
  123. data/lib/canopus/lsp/timeout.rb +0 -7
  124. data/lib/canopus/lsp/transport.rb +0 -123
  125. data/lib/canopus/lsp.rb +0 -19
  126. data/lib/canopus/project/search.rb +0 -164
  127. data/lib/canopus/project/search_worker/cancelled.rb +0 -3
  128. data/lib/canopus/project/search_worker/runner.rb +0 -9
  129. data/lib/canopus/project/search_worker.rb +0 -108
  130. data/lib/canopus/terminal/cell.rb +0 -7
  131. data/lib/canopus/terminal/grid.rb +0 -321
  132. data/lib/canopus/terminal/pty.rb +0 -178
  133. data/lib/canopus/terminal/scrollback.rb +0 -38
  134. data/lib/canopus/terminal/vt.rb +0 -398
  135. data/lib/canopus/terminal.rb +0 -13
  136. data/sig/lsp.rbs +0 -99
  137. data/sig/terminal.rbs +0 -130
@@ -4,23 +4,26 @@ require_relative "selection"
4
4
 
5
5
  module Canopus
6
6
  class Editor
7
- PAIRS = {"(" => ")", "[" => "]", "{" => "}", '"' => '"', "'" => "'"}.freeze
7
+ DEFAULT_CLOSING_PAIRS = {"(" => ")", "[" => "]", "{" => "}", '"' => '"', "'" => "'"}.freeze
8
8
  attr_reader :buffer, :display_map, :selections, :scroll_x, :scroll_y
9
- attr_accessor :tab_size, :use_tabs, :auto_pairs, :composition, :viewport_rows, :relative_line_numbers
9
+ attr_accessor :tab_size, :use_tabs, :auto_pairs, :closing_pairs, :composition, :viewport_rows, :relative_line_numbers
10
10
 
11
11
  def initialize(buffer = Buffer.new, wrap_width: nil, tab_size: 4)
12
12
  @buffer, @next_selection = buffer, 0
13
- @tab_size, @use_tabs, @auto_pairs = tab_size, false, true
13
+ @tab_size, @use_tabs, @auto_pairs, @closing_pairs = tab_size, false, true, DEFAULT_CLOSING_PAIRS
14
14
  @display_map = DisplayMap.new(buffer, wrap_width: wrap_width, tab_size: tab_size)
15
15
  @scroll_x, @scroll_y, @viewport_rows = 0, 0, 30
16
16
  select(0)
17
17
  @subscription = buffer.on_edit do |patch|
18
18
  @display_map.block_map.blocks.values.each { |block| @display_map.remove_block(block.id) if block.kind == :git_diff }
19
+ previous = @selections
19
20
  @selections = @selections.map { |s| Selection.new(s.id, patch.map_offset(s.anchor), patch.map_offset(s.head), s.goal) }.freeze
21
+ notify_selection if @selections != previous
20
22
  end
21
23
  end
22
24
  def dispose
23
25
  @subscription.detach
26
+ @selection_listeners&.clear
24
27
  @display_map.dispose
25
28
  @language_document&.dispose
26
29
  clear_snippet
@@ -40,23 +43,30 @@ module Canopus
40
43
  self
41
44
  end
42
45
 
43
- def set_selections(selections)
44
- merged = []
45
- selections.sort_by(&:start).each do |selection|
46
- if merged.last && (selection.start < merged.last.end || (selection.start == merged.last.end && !snippet_active?))
47
- previous = merged.pop
48
- merged << Selection.new(previous.id, previous.start, [previous.end, selection.end].max, selection.goal)
49
- else
50
- merged << selection
51
- end
46
+ def on_selection(&listener)
47
+ raise ArgumentError, "selection listener required" unless listener
48
+ (@selection_listeners ||= []) << listener
49
+ Zaniah::Subscription.new { @selection_listeners.delete(listener) }
50
+ end
51
+
52
+ def set_selections(selections, merge: true)
53
+ previous = @selections
54
+ unless merge
55
+ @selections = ordered_selections(selections).freeze
56
+ @buffer.selections = @selections
57
+ notify_selection if @selections != previous
58
+ return @selections
52
59
  end
53
- @selections = merged.freeze
60
+ @selections = merged_selections(selections).freeze
54
61
  @buffer.selections = @selections
62
+ notify_selection if @selections != previous
63
+ @selections
55
64
  end
56
65
 
57
- def insert_text(text, auto_indent: true)
66
+ def insert_text(text, auto_indent: true, pair: true)
58
67
  raise ArgumentError, "text must be UTF-8" unless text.is_a?(String) && text.valid_encoding?
59
- texts = @selections.map do |selection|
68
+ closer = @closing_pairs[text] if pair && @auto_pairs
69
+ texts = merged_selections(@selections, touching: false).map do |selection|
60
70
  if text == "\n" && auto_indent
61
71
  indent = language_document.indent_for(selection.start, tab_size: @tab_size, use_tabs: @use_tabs)
62
72
  if language_document.definition == Language::PLAIN
@@ -64,30 +74,40 @@ module Canopus
64
74
  indent += @use_tabs ? "\t" : " " * @tab_size if before.rstrip.end_with?("{", "[", "(")
65
75
  end
66
76
  @buffer.line_ending + indent
67
- elsif @auto_pairs && PAIRS.key?(text)
68
- text + @buffer.rope.byteslice(selection.range).to_s + PAIRS[text]
77
+ elsif closer
78
+ text + @buffer.rope.byteslice(selection.range).to_s + closer
69
79
  else
70
80
  text
71
81
  end
72
82
  end
73
- paired = @auto_pairs && PAIRS.key?(text)
74
- replace_selections(texts, kind: :typing, group: true, cursor_back: paired ? PAIRS[text].bytesize : 0)
83
+ replace_selections(texts, kind: :typing, group: true, cursor_back: closer ? closer.bytesize : 0)
75
84
  reveal_cursor
76
85
  end
77
86
 
78
87
  def replace_selections(texts, kind: :edit, group: false, cursor_back: 0, before_selections: @selections)
79
- texts = Array.new(@selections.length, texts) if texts.is_a?(String)
80
- raise ArgumentError, "one text per selection required" unless texts.length == @selections.length
81
- changes = @selections.zip(texts).map { |selection, text| [selection.range, text] }
88
+ selections = merged_selections(@selections, touching: false)
89
+ if texts.is_a?(Array)
90
+ unless [@selections.length, selections.length].include?(texts.length)
91
+ raise ArgumentError, "one text per selection required"
92
+ end
93
+ if selections.length != @selections.length
94
+ raise ArgumentError, "overlapping selections require the same text" unless texts.uniq.length == 1
95
+ texts = texts.first
96
+ end
97
+ end
98
+ texts = Array.new(selections.length, texts) if texts.is_a?(String)
99
+ raise ArgumentError, "one text per selection required" unless texts.length == selections.length
100
+ changes = selections.zip(texts).map { |selection, text| [selection.range, text] }
82
101
  @buffer.selections = before_selections
83
102
  delta = 0
84
- after = @selections.zip(texts).map do |selection, text|
103
+ after = selections.zip(texts).map do |selection, text|
85
104
  ending = selection.start + delta + text.bytesize - cursor_back
86
105
  delta += text.bytesize - (selection.end - selection.start)
87
106
  Selection.new(selection.id, ending, ending, nil)
88
107
  end
108
+ after = merged_selections(after)
89
109
  @buffer.edit(changes, kind: kind, selections: after, before_selections: before_selections, group: group)
90
- set_selections(after)
110
+ set_selections(after, merge: false)
91
111
  end
92
112
 
93
113
  def delete_backward
@@ -108,14 +128,14 @@ module Canopus
108
128
  def undo
109
129
  clear_snippet
110
130
  return false unless @buffer.undo
111
- set_selections(@buffer.selections)
131
+ set_selections(@buffer.selections, merge: false)
112
132
  reveal_cursor
113
133
  true
114
134
  end
115
135
  def redo
116
136
  clear_snippet
117
137
  return false unless @buffer.redo
118
- set_selections(@buffer.selections)
138
+ set_selections(@buffer.selections, merge: false)
119
139
  reveal_cursor
120
140
  true
121
141
  end
@@ -166,6 +186,18 @@ module Canopus
166
186
  end
167
187
 
168
188
  def select_all = select(0, @buffer.rope.bytesize)
189
+ def select_matches(pattern, **options)
190
+ matches = search(pattern, **options)
191
+ return matches if matches.empty?
192
+ selections = matches.map do |range|
193
+ selection = Selection.new(@next_selection, range.begin, range.end, nil)
194
+ @next_selection += 1
195
+ selection
196
+ end
197
+ set_selections(selections, merge: false)
198
+ reveal_cursor
199
+ matches
200
+ end
169
201
  def select_next_occurrence(all: false)
170
202
  needle = @buffer.rope.byteslice(primary.range).to_s
171
203
  if needle.empty?
@@ -186,13 +218,47 @@ module Canopus
186
218
  end
187
219
 
188
220
  def rectangle(first, last)
189
- selections = (first.row..last.row).map do |row|
190
- anchor = @display_map.to_buffer(DisplayPoint.new(row, first.column))
191
- head = @display_map.to_buffer(DisplayPoint.new(row, last.column))
221
+ selections = BlockSelection.new(first, last).filter_map do |anchor, head|
222
+ next unless @display_map.row(anchor.row).kind == :text
223
+ selection = Selection.new(@next_selection, @display_map.to_buffer(anchor), @display_map.to_buffer(head), nil)
192
224
  @next_selection += 1
193
- Selection.new(@next_selection, anchor, head, nil)
225
+ selection
194
226
  end
195
- set_selections(selections)
227
+ return if selections.empty?
228
+ set_selections(selections, merge: false)
229
+ end
230
+ def add_cursor(direction)
231
+ raise ArgumentError, "cursor direction must be up or down" unless %i[up down].include?(direction)
232
+ edge = @selections.public_send(direction == :up ? :min_by : :max_by) { |selection| @display_map.to_display(selection.head).row }
233
+ point = @display_map.to_display(edge.head)
234
+ row = point.row + (direction == :up ? -1 : 1)
235
+ column = edge.goal || point.column
236
+ offset = nil
237
+ loop do
238
+ return unless row.between?(0, @display_map.row_count - 1)
239
+ unless @display_map.row(row).kind == :text
240
+ row += direction == :up ? -1 : 1
241
+ next
242
+ end
243
+ offset = @display_map.to_buffer(DisplayPoint.new(row, column))
244
+ break unless @selections.any? { |selection| selection.empty? && selection.head == offset }
245
+ row += direction == :up ? -1 : 1
246
+ end
247
+
248
+ selection = Selection.new(@next_selection, offset, offset, column)
249
+ @next_selection += 1
250
+ set_selections([*@selections, selection], merge: false)
251
+ end
252
+ def add_cursors_to_selected_lines(edge)
253
+ raise ArgumentError, "line edge must be start or end" unless %i[start end].include?(edge)
254
+ selections = selected_rows.map do |row|
255
+ start = @buffer.rope.line_start(row)
256
+ offset = edge == :start ? start : start + @buffer.line(row).bytesize
257
+ selection = Selection.new(@next_selection, offset, offset, nil)
258
+ @next_selection += 1
259
+ selection
260
+ end
261
+ set_selections(selections, merge: false)
196
262
  end
197
263
  def indent(outdent: false)
198
264
  change_lines(:indent) do |line|
@@ -353,6 +419,31 @@ module Canopus
353
419
  end
354
420
 
355
421
  private
422
+ def notify_selection
423
+ @selection_listeners&.dup&.each do |listener|
424
+ listener.call(@selections)
425
+ rescue StandardError => error
426
+ begin
427
+ warn "Canopus selection notification failed: #{error.message}"
428
+ rescue StandardError
429
+ nil
430
+ end
431
+ end
432
+ end
433
+
434
+ def merged_selections(selections, touching: !snippet_active?)
435
+ ordered_selections(selections).each_with_object([]) do |selection, merged|
436
+ if merged.last && (selection.start < merged.last.end || (touching && selection.start == merged.last.end))
437
+ previous = merged.pop
438
+ merged << Selection.new(previous.id, previous.start, [previous.end, selection.end].max, selection.goal)
439
+ else
440
+ merged << selection
441
+ end
442
+ end
443
+ end
444
+ def ordered_selections(selections)
445
+ selections.each_with_index.sort_by { |selection, index| [selection.start, index] }.map!(&:first)
446
+ end
356
447
  def horizontal(offset, direction)
357
448
  if @buffer.rope.respond_to?(:lazy?)
358
449
  rope = @buffer.rope
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Git
5
+ class State
6
+ CACHE_LIMIT = 20
7
+ Snapshot = Data.define(:head, :branch, :index_stamp, :entries) do
8
+ def identity = [head, branch, index_stamp].freeze
9
+ end
10
+
11
+ attr_reader :snapshot
12
+
13
+ def initialize(repository)
14
+ @repository = repository
15
+ @repository_lock = Mutex.new
16
+ @diff_cache = {}
17
+ end
18
+
19
+ def capture
20
+ synchronize do |repository|
21
+ 3.times do
22
+ before = repository_identity
23
+ entries = repository.status.to_h do |entry|
24
+ [entry.path.dup.freeze, entry.code.dup.freeze]
25
+ end.freeze
26
+ after = repository_identity
27
+ return Snapshot.new(*after, entries) if before == after
28
+ end
29
+ raise Thuban::RefLockError, "Git HEAD or index changed while reading status"
30
+ end
31
+ end
32
+
33
+ def identity
34
+ synchronize do |repository|
35
+ [freeze_string(repository.head), freeze_string(repository.branch), index_stamp(repository)].freeze
36
+ end
37
+ end
38
+
39
+ def install(snapshot)
40
+ raise ArgumentError, "invalid Git snapshot" unless snapshot.is_a?(Snapshot)
41
+
42
+ @snapshot = snapshot
43
+ end
44
+
45
+ def invalidate
46
+ @snapshot = nil
47
+ @diff_cache.clear
48
+ end
49
+
50
+ def cached_diff(key) = @diff_cache[key]
51
+
52
+ def store_diff(key, diff)
53
+ @diff_cache.clear if @diff_cache.length >= CACHE_LIMIT && !@diff_cache.key?(key)
54
+ @diff_cache[key] = diff
55
+ end
56
+
57
+ def synchronize(snapshot: nil, index_lock: false, &block)
58
+ @repository_lock.synchronize do
59
+ operation = lambda do
60
+ if snapshot && snapshot.identity != repository_identity
61
+ raise Thuban::RefLockError, "Git HEAD or index changed since it was displayed"
62
+ end
63
+
64
+ block.call(@repository)
65
+ end
66
+ index_lock ? with_index_lock(&operation) : operation.call
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def freeze_string(value) = value&.dup&.freeze
73
+
74
+ def index_stamp(repository)
75
+ path = File.join(repository.git_dir, "index")
76
+ return unless File.file?(path)
77
+
78
+ stat = File.stat(path)
79
+ [stat.mtime.to_i, stat.mtime.nsec, stat.size, stat.ino].freeze
80
+ rescue Errno::ENOENT, Errno::ENOTDIR
81
+ nil
82
+ end
83
+
84
+ def repository_identity
85
+ [freeze_string(@repository.head), freeze_string(@repository.branch), index_stamp(@repository)].freeze
86
+ end
87
+
88
+ def with_index_lock
89
+ path = File.join(@repository.git_dir, "index.lock")
90
+ lock = File.open(path, File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o644)
91
+ yield
92
+ rescue Errno::EEXIST
93
+ raise Thuban::RefLockError, "Git index is locked: #{path}"
94
+ ensure
95
+ lock&.close
96
+ File.unlink(path) if lock && File.exist?(path)
97
+ end
98
+ end
99
+ end
100
+ end
@@ -8,6 +8,8 @@ module Canopus
8
8
  LINE_LIMIT = 16 << 10
9
9
  ROW_LIMIT = 256
10
10
  CACHE_ROWS = 512
11
+ SELECTION_LIMIT = 256
12
+ SELECTION_RANGE_LIMIT = 10_000
11
13
  Snapshot = Data.define(:rope, :version, :id, :name, :lexer, :first, :last, :syntax)
12
14
  attr_reader :analysis_error
13
15
 
@@ -83,10 +85,38 @@ module Canopus
83
85
  def diagnostics = (request(syntax: true); @syntax ? @syntax[:diagnostics] : EMPTY)
84
86
  def fold_ranges = (request(syntax: true); @syntax ? @syntax[:folds] : EMPTY)
85
87
  def bracket_at(offset) = (request(syntax: true); @syntax && @syntax[:brackets][offset])
88
+ def brackets(rows = nil)
89
+ request(syntax: true)
90
+ values = @syntax ? @syntax[:bracket_pairs] : EMPTY
91
+ rows ? values.select { |pair| pair.close_row >= rows.begin && pair.open_row < rows.end } : values
92
+ end
93
+ def structure_regions = (request(syntax: true); @syntax ? @syntax[:structure_regions] : EMPTY)
94
+ def selection_ranges(offsets)
95
+ unless offsets.is_a?(Array) && offsets.length.between?(1, SELECTION_LIMIT)
96
+ raise ArgumentError, "selection offsets must contain 1..#{SELECTION_LIMIT} entries"
97
+ end
98
+ positions = offsets.each_with_index.map do |offset, index|
99
+ point = @buffer.rope.point_at(offset)
100
+ [index, point.row, point.column, offset].freeze
101
+ end.freeze
102
+ unless @selection_positions&.map(&:last) == offsets
103
+ @job&.cancel
104
+ @job = nil
105
+ @selection_positions, @selection_results = positions, {}
106
+ @generation += 1
107
+ @failed = false
108
+ @analysis_error = nil
109
+ @dirty = missing_work?
110
+ end
111
+ return unless @selection_results.length == positions.length
112
+
113
+ positions.map { |index,| @selection_results.fetch(index) }.freeze
114
+ end
86
115
 
87
116
  def invalidate(patch)
88
117
  @job&.cancel
89
118
  @job = @syntax = nil
119
+ @selection_positions = @selection_results = nil
90
120
  @syntax_rows.clear
91
121
  if patch.is_a?(Patch::Reload)
92
122
  @tokens.clear
@@ -112,6 +142,12 @@ module Canopus
112
142
  result = job.future.await(timeout: 0)
113
143
  if @submitted_version == @buffer.version && @submitted_generation == @generation
114
144
  result[:tokens].each { |row, tokens| @tokens[row] = tokens; @stale.delete(row) }
145
+ if @selection_results
146
+ result[:selections].each { |index, ranges| @selection_results[index] = ranges }
147
+ if @selection_results.values.sum(&:length) > SELECTION_RANGE_LIMIT
148
+ raise Error, "too many background selection ranges"
149
+ end
150
+ end
115
151
  while @tokens.length > CACHE_ROWS
116
152
  @tokens.delete(@tokens.each_key.find { |row| !@rows.include?(row) })
117
153
  end
@@ -128,15 +164,17 @@ module Canopus
128
164
  @scheduler ||= Scheduler.acquire
129
165
  # A folded viewport can contain distant source rows. Fill one missing
130
166
  # contiguous run at a time without paint changing/cancelling its job.
131
- missing = @rows.select { |row| row_needed?(row) }
132
- missing = @rows if missing.empty?
167
+ rows = work_rows
168
+ missing = rows.select { |row| row_needed?(row) }
169
+ missing = rows if missing.empty?
133
170
  first = last = missing.first
134
171
  missing.drop(1).each do |row|
135
172
  break unless row == last + 1
136
173
  last = row
137
174
  end
138
175
  snapshot = Snapshot.new(@buffer.rope, @buffer.version, @id,
139
- @document.definition.name, @document.definition.lexer, first, last, !!(@want_syntax && !syntax_complete?))
176
+ @document.definition.name, @document.definition.lexer, first, last,
177
+ {syntax: !!(@want_syntax && !syntax_complete?), selections: @selection_positions}.freeze)
140
178
  if (@job = @scheduler.submit(snapshot, prior_syntax: @syntax))
141
179
  @submitted_version, @submitted_generation = @buffer.version, @generation
142
180
  end
@@ -162,6 +200,7 @@ module Canopus
162
200
  @provisional.clear
163
201
  @syntax_rows.clear
164
202
  @syntax = nil
203
+ @selection_positions = @selection_results = nil
165
204
  @analysis_error = nil
166
205
  end
167
206
 
@@ -205,10 +244,16 @@ module Canopus
205
244
  last = [last, end_row].min
206
245
  job.check!
207
246
  source = rope.byteslice(from, to - from).to_s.freeze
247
+ selection_request = snapshot.syntax.is_a?(Hash) ? snapshot.syntax : {}
248
+ syntax = selection_request.fetch(:syntax, snapshot.syntax == true)
249
+ selections = Array(selection_request[:selections]).filter_map do |index, row, column, _offset|
250
+ [index, row, column] if row.between?(rope.point_at(from).row, end_row)
251
+ end
208
252
  {"id" => snapshot.id, "version" => snapshot.version, "name" => snapshot.name,
209
253
  "lexer" => snapshot.lexer, "source" => source, "base" => from,
210
254
  "base_line" => rope.point_at(from).row, "first" => first, "last" => last,
211
- "syntax" => snapshot.syntax, "complete" => complete, "context" => context}.freeze
255
+ "syntax" => syntax, "selections" => selections.freeze,
256
+ "complete" => complete, "context" => context}.freeze
212
257
  end
213
258
 
214
259
  def self.decode(response, prior_syntax: nil)
@@ -220,17 +265,39 @@ module Canopus
220
265
  outline = raw.fetch("outline").map do |name, kind, first, last, start, finish, depth|
221
266
  Language::Symbol.new(name.freeze, kind.to_sym, (first...last).freeze, (start...finish).freeze, depth)
222
267
  end.freeze
223
- brackets = {}
224
- raw.fetch("brackets").each do |first, last|
225
- range = (first...last).freeze
226
- brackets[first] = brackets[last - 1] = range
268
+ bracket_pairs = raw.fetch("brackets").map do |open_first, open_last, open_row, close_first, close_last, close_row, depth|
269
+ Language::Bracket.new((open_first...open_last).freeze, (close_first...close_last).freeze,
270
+ open_row, close_row, depth).freeze
271
+ end.freeze
272
+ brackets = bracket_pairs.each_with_object({}) do |pair, result|
273
+ range = (pair.open_range.begin...pair.close_range.end).freeze
274
+ result[pair.open_range.begin] = result[pair.close_range.begin] = range
227
275
  end
228
276
  {outline: outline, diagnostics: raw.fetch("diagnostics").map { |first, last, message| {range: (first...last).freeze, message: message.freeze, severity: 1}.freeze }.freeze,
229
277
  folds: raw.fetch("folds").map { |first, last| (first...last).freeze }.freeze,
230
- brackets: brackets.freeze, complete: response.fetch("complete")}.freeze
278
+ brackets: brackets.freeze, bracket_pairs: bracket_pairs,
279
+ structure_regions: raw.fetch("structure_regions").map { |first, last, kind| {start_line: first, end_line: last, kind: kind.to_sym}.freeze }.freeze,
280
+ complete: response.fetch("complete")}.freeze
231
281
  end
232
282
  syntax = merge_syntax(prior_syntax, syntax) if prior_syntax && syntax && !syntax[:complete]
233
- {tokens: tokens, syntax: syntax}.freeze
283
+ values = response.fetch("selections", [])
284
+ unless values.is_a?(Array) && values.length <= SELECTION_LIMIT
285
+ raise Error, "invalid background selection ranges"
286
+ end
287
+ seen, total = {}, 0
288
+ selections = values.to_h do |index, ranges|
289
+ total += ranges.length if ranges.is_a?(Array)
290
+ unless index.is_a?(Integer) && index.between?(0, SELECTION_LIMIT - 1) && !seen.key?(index) &&
291
+ ranges.is_a?(Array) && total <= SELECTION_RANGE_LIMIT && ranges.all? do |range|
292
+ range.is_a?(Array) && range.length == 2 && range.all? { |value| value.is_a?(Integer) && value >= 0 } &&
293
+ range[0] <= range[1]
294
+ end
295
+ raise Error, "invalid background selection ranges"
296
+ end
297
+ seen[index] = true
298
+ [index, ranges.map { |first, last| (first...last).freeze }.freeze]
299
+ end.freeze
300
+ {tokens: tokens, syntax: syntax, selections: selections}.freeze
234
301
  end
235
302
 
236
303
  # Decoding and bounded merging happen on a preparation thread, never in
@@ -238,17 +305,29 @@ module Canopus
238
305
  def self.merge_syntax(previous, current)
239
306
  return previous if previous[:complete]
240
307
  outlines = (previous[:outline] + current[:outline]).uniq { |item| [item.name, item.kind, item.range, item.selection] }
241
- brackets = (previous[:brackets].values + current[:brackets].values).uniq.first(8192)
308
+ bracket_pairs = (previous.fetch(:bracket_pairs, []) + current.fetch(:bracket_pairs, []))
309
+ .uniq { |pair| [pair.open_range, pair.close_range] }.first(8192)
310
+ brackets = bracket_pairs.each_with_object({}) do |pair, result|
311
+ range = (pair.open_range.begin...pair.close_range.end).freeze
312
+ result[pair.open_range.begin] = result[pair.close_range.begin] = range
313
+ end
314
+ structure_regions = (previous.fetch(:structure_regions, []) + current.fetch(:structure_regions, []))
315
+ .uniq { |region| [region[:start_line], region[:end_line], region[:kind]] }.first(10_000)
242
316
  {outline: outlines.sort_by { |item| item.selection.begin }.first(10_000).freeze,
243
317
  diagnostics: (previous[:diagnostics] + current[:diagnostics]).uniq.first(1000).freeze,
244
318
  folds: (previous[:folds] + current[:folds]).uniq.sort_by(&:begin).first(10_000).freeze,
245
- brackets: brackets.each_with_object({}) { |range, result| result[range.begin] = result[range.end - 1] = range }.freeze,
319
+ brackets: brackets.freeze, bracket_pairs: bracket_pairs.freeze,
320
+ structure_regions: structure_regions.freeze,
246
321
  complete: false}.freeze
247
322
  end
248
323
 
249
324
  private
250
- def row_needed?(row) = !@tokens.key?(row) || (@want_syntax && !syntax_complete? && !@syntax_rows.key?(row))
251
- def missing_work? = @rows.any? { |row| row_needed?(row) }
325
+ def row_needed?(row)
326
+ !@tokens.key?(row) || (@want_syntax && !syntax_complete? && !@syntax_rows.key?(row)) ||
327
+ @selection_positions&.any? { |index, selection_row,| selection_row == row && !@selection_results.key?(index) }
328
+ end
329
+ def work_rows = [*@rows, *@selection_positions&.map { |_, row,| row }].compact.uniq.sort
330
+ def missing_work? = work_rows.any? { |row| row_needed?(row) }
252
331
 
253
332
  def retain_unchanged_rows(patch)
254
333
  return if patch.edits.empty?
@@ -29,14 +29,15 @@ module Canopus
29
29
  highlighter.tokens_for(row)
30
30
  end
31
31
  # Explicit synchronous escape hatch. Asynchronous UI paths never call this.
32
- def highlighter(strategy: nil, window_context: nil)
32
+ def highlighter(strategy: nil, window_context: nil, max_seconds: nil)
33
33
  return @highlighter if @highlighter
34
- require "antares"
34
+ require "antares" unless defined?(Antares::Highlighter)
35
35
  large = @buffer.rope.bytesize > 1 << 20
36
36
  @highlighter = Antares::Highlighter.new(lexer: Rouge::Lexer.find(@definition.lexer).new,
37
37
  lines: ->(row) { @buffer.rope.byteslice(@buffer.rope.line_start(row), (row + 1 < @buffer.line_count ? @buffer.rope.line_start(row + 1) : @buffer.rope.bytesize) - @buffer.rope.line_start(row)).to_s },
38
38
  line_count: -> { @buffer.line_count }, strategy: strategy || (large ? :window : :auto),
39
- window_context: window_context || (@buffer.rope.bytesize > 10 << 20 ? 0 : 500))
39
+ window_context: window_context || (@buffer.rope.bytesize > 10 << 20 ? 0 : 500),
40
+ max_seconds: max_seconds || 0.25)
40
41
  end
41
42
  def invalidate(patch)
42
43
  @background&.invalidate(patch)
@@ -88,7 +89,11 @@ module Canopus
88
89
  end
89
90
  def fold_ranges
90
91
  return @background.fold_ranges if @background
91
- outline.filter_map { |symbol| symbol.range if @buffer.rope.point_at(symbol.range.end).row > @buffer.rope.point_at(symbol.range.begin).row }
92
+ structure_regions.map do |region|
93
+ first = @buffer.rope.line_start(region[:start_line]) + @buffer.line(region[:start_line]).bytesize
94
+ last = @buffer.rope.line_start(region[:end_line]) + @buffer.line(region[:end_line]).bytesize
95
+ (first...last).freeze
96
+ end
92
97
  end
93
98
  def indent_for(offset, tab_size: 4, use_tabs: false)
94
99
  row = @buffer.rope.point_at(offset).row
@@ -101,42 +106,73 @@ module Canopus
101
106
  return @background.bracket_at(offset) if @background
102
107
  bracket_ranges[offset]
103
108
  end
109
+ def brackets(rows = nil)
110
+ rows = bracket_rows(rows) if rows
111
+ return @background.brackets(rows) if @background
112
+ values = structure_brackets
113
+ rows ? values.select { |pair| pair.close_row >= rows.begin && pair.open_row < rows.end } : values.dup
114
+ end
115
+ def structure_regions
116
+ return @background.structure_regions if @background
117
+ highlighter.structure.fold_regions.map do |region|
118
+ {start_line: region.start_line, end_line: region.end_line, kind: region.kind}.freeze
119
+ end.freeze
120
+ end
121
+ def selection_ranges(offsets)
122
+ return @background.selection_ranges(offsets) if @background
123
+ offsets.map do |offset|
124
+ point = @buffer.rope.point_at(offset)
125
+ ranges = highlighter.structure.selection_ranges(point.row, point.column).map do |region|
126
+ first = @buffer.rope.offset_at(Denebola::Point.new(region.start_line, region.start_column || 0))
127
+ last = @buffer.rope.offset_at(Denebola::Point.new(region.end_line,
128
+ region.end_column || @buffer.line(region.end_line).length))
129
+ (first...last).freeze
130
+ end.uniq.sort_by { |range| [range.size, -range.begin] }
131
+ ranges.each_with_object([]) do |range, nested|
132
+ nested << range if !nested.last || (range.begin <= nested.last.begin && nested.last.end <= range.end)
133
+ end.freeze
134
+ end.freeze
135
+ end
104
136
 
105
137
  private
138
+ def bracket_rows(rows)
139
+ unless rows.is_a?(Range) && rows.begin.is_a?(Integer) && rows.end.is_a?(Integer) && rows.begin >= 0
140
+ raise ArgumentError, "bracket rows must be a nonnegative integer range"
141
+ end
142
+ last = rows.exclude_end? ? rows.end : rows.end + 1
143
+ raise RangeError, "bracket rows outside document" if last < rows.begin || last > @buffer.line_count
144
+ rows.begin...last
145
+ end
146
+
106
147
  def bracket_ranges
107
- return @brackets if @brackets_version == @buffer.version
108
- pairs = {"(" => ")", "[" => "]", "{" => "}"}
109
- tokens = if @definition.name == "ruby"
110
- require "prism"
111
- Prism.lex(@buffer.text).value.filter_map do |token, _state|
112
- next unless %w[PARENTHESIS_LEFT PARENTHESIS_RIGHT BRACKET_LEFT BRACKET_RIGHT BRACE_LEFT BRACE_RIGHT].include?(token.type.to_s)
113
- [token.location.start_offset, token.value]
114
- end
115
- else
116
- require "rouge"
117
- collected, byte = [], 0
118
- Rouge::Lexer.find(@definition.lexer).new.lex(@buffer.text).each do |token, value|
119
- unless token.qualname.start_with?("Literal.String", "Comment")
120
- value.b.scan(/[()\[\]{}]/n) { |character| collected << [byte + Regexp.last_match.begin(0), character] }
121
- end
122
- byte += value.bytesize
123
- end
124
- collected
148
+ return @bracket_ranges if @bracket_ranges_version == @buffer.version
149
+
150
+ @bracket_ranges = structure_brackets.each_with_object({}) do |pair, ranges|
151
+ range = (pair.open_range.begin...pair.close_range.end).freeze
152
+ ranges[pair.open_range.begin] = ranges[pair.close_range.begin] = range
125
153
  end
126
- stack, @brackets = [], {}
127
- tokens.each do |position, character|
128
- if pairs.key?(character)
129
- stack << [position, character]
130
- elsif stack.last && pairs[stack.last[1]] == character
131
- opening = stack.pop[0]
132
- range = (opening...position + character.bytesize).freeze
133
- @brackets[opening] = @brackets[position] = range
134
- else
135
- stack.clear
154
+ @bracket_ranges_version = @buffer.version
155
+ @bracket_ranges
156
+ end
157
+
158
+ def structure_brackets
159
+ return @structure_brackets if @structure_brackets_version == @buffer.version
160
+
161
+ boundaries = {}
162
+ byte_offset = lambda do |row, column|
163
+ positions = boundaries[row] ||= @buffer.line(row).each_char.each_with_object([0]) do |character, values|
164
+ values << values.last + character.bytesize
136
165
  end
166
+ @buffer.rope.line_start(row) + positions.fetch(column)
137
167
  end
138
- @brackets_version = @buffer.version
139
- @brackets
168
+ @structure_brackets = highlighter.structure.brackets.map do |pair|
169
+ opening = byte_offset.call(pair.open_line, pair.open_column)
170
+ closing = byte_offset.call(pair.close_line, pair.close_column)
171
+ Bracket.new((opening...opening + 1).freeze, (closing...closing + 1).freeze,
172
+ pair.open_line, pair.close_line, pair.depth).freeze
173
+ end.freeze
174
+ @structure_brackets_version = @buffer.version
175
+ @structure_brackets
140
176
  end
141
177
  end
142
178
  end