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,280 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module Language
|
|
5
|
+
class BackgroundAnalysis
|
|
6
|
+
EMPTY = [].freeze
|
|
7
|
+
SOURCE_LIMIT = 2 << 20
|
|
8
|
+
LINE_LIMIT = 16 << 10
|
|
9
|
+
ROW_LIMIT = 256
|
|
10
|
+
CACHE_ROWS = 512
|
|
11
|
+
Snapshot = Data.define(:rope, :version, :id, :name, :lexer, :first, :last, :syntax)
|
|
12
|
+
attr_reader :analysis_error
|
|
13
|
+
|
|
14
|
+
def initialize(document)
|
|
15
|
+
@document, @buffer = document, document.buffer
|
|
16
|
+
@id = "#{Process.pid}:#{object_id}"
|
|
17
|
+
@tokens, @stale, @provisional, @generation = {}, {}, {}, 0
|
|
18
|
+
@syntax_rows = {}
|
|
19
|
+
@disabled = @buffer.rope.respond_to?(:lazy?) && @buffer.rope.lazy?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def tokens_for(row)
|
|
23
|
+
validate_row(row)
|
|
24
|
+
return @tokens[row] if @tokens.key?(row)
|
|
25
|
+
unless @disabled || @disposed || @explicit_rows
|
|
26
|
+
first, last = @range || [row, row]
|
|
27
|
+
if row < first
|
|
28
|
+
first, last = row, [last, row + ROW_LIMIT - 1].min
|
|
29
|
+
elsif row > last
|
|
30
|
+
first, last = [first, row - ROW_LIMIT + 1].max, row
|
|
31
|
+
end
|
|
32
|
+
request(first_line: first, last_line: last)
|
|
33
|
+
end
|
|
34
|
+
return @stale[row] if @stale.key?(row)
|
|
35
|
+
return @provisional[row] if @provisional.key?(row)
|
|
36
|
+
@provisional.shift if @provisional.length >= CACHE_ROWS
|
|
37
|
+
@provisional[row] = [["Text", self.class.line(@buffer.rope, row)].freeze].freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def request(first_line: nil, last_line: nil, rows: nil, syntax: false)
|
|
41
|
+
return if @disabled || @disposed
|
|
42
|
+
if rows
|
|
43
|
+
unless rows.is_a?(Array) && rows.length.between?(1, ROW_LIMIT) && first_line.nil? && last_line.nil?
|
|
44
|
+
raise ArgumentError, "language rows must contain 1..#{ROW_LIMIT} entries without a range"
|
|
45
|
+
end
|
|
46
|
+
rows.each { |row| validate_row(row) }
|
|
47
|
+
rows = rows.uniq.sort.freeze
|
|
48
|
+
@explicit_rows = true
|
|
49
|
+
elsif first_line || last_line || !@rows
|
|
50
|
+
first = first_line || @range&.first || 0
|
|
51
|
+
last = last_line || @range&.last || first
|
|
52
|
+
validate_row(first)
|
|
53
|
+
validate_row(last)
|
|
54
|
+
raise ArgumentError, "inverted language range" if last < first
|
|
55
|
+
rows = (first..[last, first + ROW_LIMIT - 1].min).to_a.freeze
|
|
56
|
+
@explicit_rows = false
|
|
57
|
+
else
|
|
58
|
+
rows = @rows
|
|
59
|
+
end
|
|
60
|
+
changed = rows != @rows || (syntax && !@want_syntax)
|
|
61
|
+
if rows != @rows && !syntax_complete?
|
|
62
|
+
@syntax = nil
|
|
63
|
+
@syntax_rows.clear
|
|
64
|
+
end
|
|
65
|
+
@want_syntax ||= syntax
|
|
66
|
+
if changed
|
|
67
|
+
@job&.cancel
|
|
68
|
+
@job = nil
|
|
69
|
+
@generation += 1
|
|
70
|
+
@failed = false
|
|
71
|
+
@analysis_error = nil
|
|
72
|
+
end
|
|
73
|
+
@rows, @range = rows, [rows.first, rows.last].freeze
|
|
74
|
+
@dirty = !@failed && missing_work?
|
|
75
|
+
self
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def pending? = !@disposed && !@disabled && (!!@job || !!@dirty)
|
|
79
|
+
def tokens_current?(row) = @tokens.key?(row)
|
|
80
|
+
def syntax_ready? = !!@syntax
|
|
81
|
+
def syntax_complete? = !!(@syntax && @syntax[:complete])
|
|
82
|
+
def outline = (request(syntax: true); @syntax ? @syntax[:outline] : EMPTY)
|
|
83
|
+
def diagnostics = (request(syntax: true); @syntax ? @syntax[:diagnostics] : EMPTY)
|
|
84
|
+
def fold_ranges = (request(syntax: true); @syntax ? @syntax[:folds] : EMPTY)
|
|
85
|
+
def bracket_at(offset) = (request(syntax: true); @syntax && @syntax[:brackets][offset])
|
|
86
|
+
|
|
87
|
+
def invalidate(patch)
|
|
88
|
+
@job&.cancel
|
|
89
|
+
@job = @syntax = nil
|
|
90
|
+
@syntax_rows.clear
|
|
91
|
+
if patch.is_a?(Patch::Reload)
|
|
92
|
+
@tokens.clear
|
|
93
|
+
@stale.clear
|
|
94
|
+
else
|
|
95
|
+
(patch.is_a?(Patch::Composite) ? patch.patches : [patch]).each { |item| retain_unchanged_rows(item) }
|
|
96
|
+
end
|
|
97
|
+
@provisional.clear
|
|
98
|
+
@generation += 1
|
|
99
|
+
@failed = false
|
|
100
|
+
@analysis_error = nil
|
|
101
|
+
@disabled = @buffer.rope.respond_to?(:lazy?) && @buffer.rope.lazy?
|
|
102
|
+
@rows = @rows&.map { |row| [row, @buffer.line_count - 1].min }&.uniq&.freeze
|
|
103
|
+
@range = [@rows.first, @rows.last].freeze if @rows
|
|
104
|
+
@dirty = !!@rows && !@disabled
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def poll
|
|
108
|
+
return false unless pending?
|
|
109
|
+
changed = false
|
|
110
|
+
if @job&.future&.done?
|
|
111
|
+
job, @job = @job, nil
|
|
112
|
+
result = job.future.await(timeout: 0)
|
|
113
|
+
if @submitted_version == @buffer.version && @submitted_generation == @generation
|
|
114
|
+
result[:tokens].each { |row, tokens| @tokens[row] = tokens; @stale.delete(row) }
|
|
115
|
+
while @tokens.length > CACHE_ROWS
|
|
116
|
+
@tokens.delete(@tokens.each_key.find { |row| !@rows.include?(row) })
|
|
117
|
+
end
|
|
118
|
+
@stale.shift while @stale.length + @tokens.length > CACHE_ROWS
|
|
119
|
+
if result[:syntax]
|
|
120
|
+
@syntax = result[:syntax]
|
|
121
|
+
result[:tokens].each_key { |row| @syntax_rows[row] = true }
|
|
122
|
+
end
|
|
123
|
+
@dirty = missing_work?
|
|
124
|
+
changed = true
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
if @dirty && !@job
|
|
128
|
+
@scheduler ||= Scheduler.acquire
|
|
129
|
+
# A folded viewport can contain distant source rows. Fill one missing
|
|
130
|
+
# 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?
|
|
133
|
+
first = last = missing.first
|
|
134
|
+
missing.drop(1).each do |row|
|
|
135
|
+
break unless row == last + 1
|
|
136
|
+
last = row
|
|
137
|
+
end
|
|
138
|
+
snapshot = Snapshot.new(@buffer.rope, @buffer.version, @id,
|
|
139
|
+
@document.definition.name, @document.definition.lexer, first, last, !!(@want_syntax && !syntax_complete?))
|
|
140
|
+
if (@job = @scheduler.submit(snapshot, prior_syntax: @syntax))
|
|
141
|
+
@submitted_version, @submitted_generation = @buffer.version, @generation
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
changed
|
|
145
|
+
rescue StandardError => error
|
|
146
|
+
@job&.cancel
|
|
147
|
+
@job = nil
|
|
148
|
+
@failed, @dirty = true, false
|
|
149
|
+
@analysis_error = error
|
|
150
|
+
raise
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def dispose
|
|
154
|
+
return if @disposed
|
|
155
|
+
@disposed = true
|
|
156
|
+
@job&.cancel
|
|
157
|
+
@job = nil
|
|
158
|
+
Scheduler.release(@scheduler) if @scheduler
|
|
159
|
+
@scheduler = nil
|
|
160
|
+
@tokens.clear
|
|
161
|
+
@stale.clear
|
|
162
|
+
@provisional.clear
|
|
163
|
+
@syntax_rows.clear
|
|
164
|
+
@syntax = nil
|
|
165
|
+
@analysis_error = nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def self.line(rope, row)
|
|
169
|
+
first = rope.line_start(row)
|
|
170
|
+
last = row + 1 < rope.line_count ? rope.line_start(row + 1) : rope.bytesize
|
|
171
|
+
ending = boundary(rope, [last, first + LINE_LIMIT].min)
|
|
172
|
+
rope.byteslice(first, ending - first).to_s.freeze
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def self.boundary(rope, offset)
|
|
176
|
+
rope.point_at(offset)
|
|
177
|
+
offset
|
|
178
|
+
rescue RangeError
|
|
179
|
+
offset -= 1
|
|
180
|
+
retry
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def self.prepare(snapshot, job)
|
|
184
|
+
rope, first, last = snapshot.rope, snapshot.first, snapshot.last
|
|
185
|
+
complete = rope.bytesize <= 1 << 20
|
|
186
|
+
from, to, context = 0, rope.bytesize, 0
|
|
187
|
+
unless complete
|
|
188
|
+
context = rope.bytesize <= 10 << 20 ? 500 : 0
|
|
189
|
+
start_row = [first - context, 0].max
|
|
190
|
+
finish_row = [last + context + 1, rope.line_count].min
|
|
191
|
+
requested_end = last + 1 < rope.line_count ? rope.line_start(last + 1) : rope.bytesize
|
|
192
|
+
while start_row < first && requested_end - rope.line_start(start_row) > SOURCE_LIMIT
|
|
193
|
+
job.check!
|
|
194
|
+
start_row += 1
|
|
195
|
+
end
|
|
196
|
+
from = rope.line_start(start_row)
|
|
197
|
+
to = finish_row < rope.line_count ? rope.line_start(finish_row) : rope.bytesize
|
|
198
|
+
to = boundary(rope, [to, from + SOURCE_LIMIT].min)
|
|
199
|
+
end
|
|
200
|
+
# A capped snapshot may end before later requested rows, or exactly at
|
|
201
|
+
# the start of a row whose content was not copied. Never cache that row
|
|
202
|
+
# as an empty, supposedly current token result; the next batch reads it.
|
|
203
|
+
end_row = rope.point_at(to).row
|
|
204
|
+
end_row -= 1 if to < rope.bytesize && rope.line_start(end_row) == to
|
|
205
|
+
last = [last, end_row].min
|
|
206
|
+
job.check!
|
|
207
|
+
source = rope.byteslice(from, to - from).to_s.freeze
|
|
208
|
+
{"id" => snapshot.id, "version" => snapshot.version, "name" => snapshot.name,
|
|
209
|
+
"lexer" => snapshot.lexer, "source" => source, "base" => from,
|
|
210
|
+
"base_line" => rope.point_at(from).row, "first" => first, "last" => last,
|
|
211
|
+
"syntax" => snapshot.syntax, "complete" => complete, "context" => context}.freeze
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def self.decode(response, prior_syntax: nil)
|
|
215
|
+
tokens = response.fetch("tokens").to_h do |row, pairs|
|
|
216
|
+
[row, pairs.map { |name, value| [name.freeze, value.freeze].freeze }.freeze]
|
|
217
|
+
end.freeze
|
|
218
|
+
raw = response["syntax"]
|
|
219
|
+
syntax = if raw
|
|
220
|
+
outline = raw.fetch("outline").map do |name, kind, first, last, start, finish, depth|
|
|
221
|
+
Language::Symbol.new(name.freeze, kind.to_sym, (first...last).freeze, (start...finish).freeze, depth)
|
|
222
|
+
end.freeze
|
|
223
|
+
brackets = {}
|
|
224
|
+
raw.fetch("brackets").each do |first, last|
|
|
225
|
+
range = (first...last).freeze
|
|
226
|
+
brackets[first] = brackets[last - 1] = range
|
|
227
|
+
end
|
|
228
|
+
{outline: outline, diagnostics: raw.fetch("diagnostics").map { |first, last, message| {range: (first...last).freeze, message: message.freeze, severity: 1}.freeze }.freeze,
|
|
229
|
+
folds: raw.fetch("folds").map { |first, last| (first...last).freeze }.freeze,
|
|
230
|
+
brackets: brackets.freeze, complete: response.fetch("complete")}.freeze
|
|
231
|
+
end
|
|
232
|
+
syntax = merge_syntax(prior_syntax, syntax) if prior_syntax && syntax && !syntax[:complete]
|
|
233
|
+
{tokens: tokens, syntax: syntax}.freeze
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Decoding and bounded merging happen on a preparation thread, never in
|
|
237
|
+
# paint/poll. Overlapping context windows must not duplicate symbols.
|
|
238
|
+
def self.merge_syntax(previous, current)
|
|
239
|
+
return previous if previous[:complete]
|
|
240
|
+
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)
|
|
242
|
+
{outline: outlines.sort_by { |item| item.selection.begin }.first(10_000).freeze,
|
|
243
|
+
diagnostics: (previous[:diagnostics] + current[:diagnostics]).uniq.first(1000).freeze,
|
|
244
|
+
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,
|
|
246
|
+
complete: false}.freeze
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
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) }
|
|
252
|
+
|
|
253
|
+
def retain_unchanged_rows(patch)
|
|
254
|
+
return if patch.edits.empty?
|
|
255
|
+
changes = patch.edits.map do |edit|
|
|
256
|
+
first, last = patch.before.point_at(edit.old_range.begin).row, patch.before.point_at(edit.old_range.end).row
|
|
257
|
+
inserted = patch.after.point_at(edit.new_range.end).row - patch.after.point_at(edit.new_range.begin).row
|
|
258
|
+
[first, last, inserted - (last - first)]
|
|
259
|
+
end
|
|
260
|
+
earliest = changes.map(&:first).min
|
|
261
|
+
fresh, stale = {}, {}
|
|
262
|
+
[@stale, @tokens].each do |cache|
|
|
263
|
+
cache.each do |row, tokens|
|
|
264
|
+
next if changes.any? { |first, last, _| row.between?(first, last) }
|
|
265
|
+
shift = changes.sum { |_, last, delta| row > last ? delta : 0 }
|
|
266
|
+
destination = cache.equal?(@tokens) && row < earliest ? fresh : stale
|
|
267
|
+
destination[row + shift] = tokens
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
@tokens, @stale = fresh, stale
|
|
271
|
+
end
|
|
272
|
+
def validate_row(row)
|
|
273
|
+
raise RangeError, "language row outside buffer" unless row.is_a?(Integer) && row >= 0 && row < @buffer.line_count
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
require_relative "background_analysis/job"
|
|
280
|
+
require_relative "background_analysis/scheduler"
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module Language
|
|
5
|
+
class Document
|
|
6
|
+
attr_reader :buffer, :definition
|
|
7
|
+
def initialize(buffer, definition: Language.for_path(buffer.path), background: true)
|
|
8
|
+
@buffer, @definition, @syntax_version = buffer, definition, -1
|
|
9
|
+
@background = BackgroundAnalysis.new(self) if background
|
|
10
|
+
@subscription = buffer.on_edit { |patch| invalidate(patch) }
|
|
11
|
+
end
|
|
12
|
+
def dispose
|
|
13
|
+
@subscription.detach
|
|
14
|
+
@background&.dispose
|
|
15
|
+
end
|
|
16
|
+
def background? = !!@background
|
|
17
|
+
def pending? = @background ? @background.pending? : false
|
|
18
|
+
def analysis_error = @background&.analysis_error
|
|
19
|
+
def poll = @background ? @background.poll : false
|
|
20
|
+
def request(first_line: nil, last_line: nil, rows: nil, syntax: false)
|
|
21
|
+
@background&.request(first_line: first_line, last_line: last_line, rows: rows, syntax: syntax)
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
def syntax_complete? = @background ? @background.syntax_complete? : true
|
|
25
|
+
def syntax_ready? = @background ? @background.syntax_ready? : true
|
|
26
|
+
def tokens_current?(row) = @background ? @background.tokens_current?(row) : true
|
|
27
|
+
def tokens_for(row)
|
|
28
|
+
return @background.tokens_for(row) if @background
|
|
29
|
+
highlighter.tokens_for(row)
|
|
30
|
+
end
|
|
31
|
+
# Explicit synchronous escape hatch. Asynchronous UI paths never call this.
|
|
32
|
+
def highlighter(strategy: nil, window_context: nil)
|
|
33
|
+
return @highlighter if @highlighter
|
|
34
|
+
require "antares"
|
|
35
|
+
large = @buffer.rope.bytesize > 1 << 20
|
|
36
|
+
@highlighter = Antares::Highlighter.new(lexer: Rouge::Lexer.find(@definition.lexer).new,
|
|
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
|
+
line_count: -> { @buffer.line_count }, strategy: strategy || (large ? :window : :auto),
|
|
39
|
+
window_context: window_context || (@buffer.rope.bytesize > 10 << 20 ? 0 : 500))
|
|
40
|
+
end
|
|
41
|
+
def invalidate(patch)
|
|
42
|
+
@background&.invalidate(patch)
|
|
43
|
+
return unless @highlighter
|
|
44
|
+
if patch.is_a?(Patch::Composite) || patch.is_a?(Patch::Reload)
|
|
45
|
+
@highlighter = nil
|
|
46
|
+
else
|
|
47
|
+
first = patch.edits.map { |edit| patch.before.point_at(edit.old_range.begin).row }.min
|
|
48
|
+
old_last = patch.edits.map { |edit| patch.before.point_at(edit.old_range.end).row }.max
|
|
49
|
+
new_last = patch.edits.map { |edit| patch.after.point_at(edit.new_range.end).row }.max
|
|
50
|
+
@highlighter.edit(from_line: first, removed: old_last - first + 1, inserted: new_last - first + 1)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
# Explicit local Prism API; use outline/diagnostics for nonblocking UI work.
|
|
54
|
+
def ruby_syntax
|
|
55
|
+
return nil unless @definition.name == "ruby"
|
|
56
|
+
if @syntax_version != @buffer.version
|
|
57
|
+
require "prism"
|
|
58
|
+
@syntax = Prism.parse(@buffer.text)
|
|
59
|
+
@syntax_version = @buffer.version
|
|
60
|
+
end
|
|
61
|
+
@syntax
|
|
62
|
+
end
|
|
63
|
+
def outline
|
|
64
|
+
return @background.outline if @background
|
|
65
|
+
return [] unless ruby_syntax
|
|
66
|
+
symbols, stack = [], [[ruby_syntax.value, 0]]
|
|
67
|
+
until stack.empty?
|
|
68
|
+
node, depth = stack.pop
|
|
69
|
+
kind = case node
|
|
70
|
+
when Prism::DefNode then :method
|
|
71
|
+
when Prism::ClassNode then :class
|
|
72
|
+
when Prism::ModuleNode then :module
|
|
73
|
+
end
|
|
74
|
+
if kind
|
|
75
|
+
name = kind == :method ? node.name.to_s : node.constant_path.slice
|
|
76
|
+
location = kind == :method ? node.name_loc : node.constant_path.location
|
|
77
|
+
symbols << Symbol.new(name, kind, node.location.start_offset...node.location.end_offset,
|
|
78
|
+
location.start_offset...location.end_offset, depth)
|
|
79
|
+
end
|
|
80
|
+
node.compact_child_nodes.reverse_each { |child| stack << [child, depth + (kind ? 1 : 0)] }
|
|
81
|
+
end
|
|
82
|
+
symbols
|
|
83
|
+
end
|
|
84
|
+
def diagnostics
|
|
85
|
+
return @background.diagnostics if @background
|
|
86
|
+
return [] unless ruby_syntax
|
|
87
|
+
ruby_syntax.errors.map { |error| {range: error.location.start_offset...error.location.end_offset, message: error.message, severity: 1} }
|
|
88
|
+
end
|
|
89
|
+
def fold_ranges
|
|
90
|
+
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
|
+
end
|
|
93
|
+
def indent_for(offset, tab_size: 4, use_tabs: false)
|
|
94
|
+
row = @buffer.rope.point_at(offset).row
|
|
95
|
+
before = @buffer.rope.byteslice(@buffer.rope.line_start(row), offset - @buffer.rope.line_start(row)).to_s
|
|
96
|
+
indentation = before[/\A[ \t]*/]
|
|
97
|
+
indentation += use_tabs ? "\t" : " " * tab_size if @definition.indent_open.match?(before)
|
|
98
|
+
indentation
|
|
99
|
+
end
|
|
100
|
+
def bracket_at(offset)
|
|
101
|
+
return @background.bracket_at(offset) if @background
|
|
102
|
+
bracket_ranges[offset]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
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
|
|
125
|
+
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
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
@brackets_version = @buffer.version
|
|
139
|
+
@brackets
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../canopus"
|
|
4
|
+
|
|
5
|
+
module Canopus
|
|
6
|
+
module Language
|
|
7
|
+
# Fixed ProcessPool handler. This state never lives on the UI thread.
|
|
8
|
+
module SyntaxWorker
|
|
9
|
+
MAX_DOCUMENTS = 4
|
|
10
|
+
MAX_CACHE_BYTES = 4 << 20
|
|
11
|
+
MAX_SYMBOLS = 10_000
|
|
12
|
+
MAX_BRACKETS = 8192
|
|
13
|
+
|
|
14
|
+
def self.call(request)
|
|
15
|
+
source = request.fetch("source")
|
|
16
|
+
unless source.is_a?(String) && source.valid_encoding? && source.encoding == Encoding::UTF_8 && source.bytesize <= BackgroundAnalysis::SOURCE_LIMIT
|
|
17
|
+
raise ArgumentError, "language snapshot must be bounded UTF-8"
|
|
18
|
+
end
|
|
19
|
+
first_row, last_row, base_line, base = request.values_at("first", "last", "base_line", "base")
|
|
20
|
+
unless [first_row, last_row, base_line, base].all? { |value| value.is_a?(Integer) && value >= 0 } &&
|
|
21
|
+
base_line <= first_row && last_row >= first_row && last_row - first_row < BackgroundAnalysis::ROW_LIMIT
|
|
22
|
+
raise ArgumentError, "language token range exceeds limit"
|
|
23
|
+
end
|
|
24
|
+
@documents ||= {}
|
|
25
|
+
key = [request.fetch("id"), base, request.fetch("name"), request.fetch("lexer"), request.fetch("complete")]
|
|
26
|
+
cached = @documents.delete(key)
|
|
27
|
+
if cached
|
|
28
|
+
buffer, document, previous = cached
|
|
29
|
+
update(buffer, previous, source) unless previous == source
|
|
30
|
+
else
|
|
31
|
+
buffer = Buffer.new(source)
|
|
32
|
+
definition = Language::Definition.new(request.fetch("name"), request.fetch("lexer"), [], "", /\A\z/, /\A\z/, [])
|
|
33
|
+
document = Document.new(buffer, definition: definition, background: false)
|
|
34
|
+
end
|
|
35
|
+
@documents[key] = [buffer, document, source]
|
|
36
|
+
while @documents.length > MAX_DOCUMENTS || @documents.values.sum { |entry| entry[2].bytesize } > MAX_CACHE_BYTES
|
|
37
|
+
_, discarded = @documents.shift
|
|
38
|
+
discarded[1].dispose
|
|
39
|
+
end
|
|
40
|
+
first = request.fetch("first") - request.fetch("base_line")
|
|
41
|
+
last = [request.fetch("last") - request.fetch("base_line"), buffer.line_count - 1].min
|
|
42
|
+
highlighter = document.highlighter(strategy: request.fetch("complete") ? nil : :window,
|
|
43
|
+
window_context: request.fetch("complete") ? nil : request.fetch("context"))
|
|
44
|
+
rows = first <= last ? highlighter.tokens_in(first..last) : []
|
|
45
|
+
budget = 65_536
|
|
46
|
+
tokens = (request.fetch("first")..request.fetch("last")).map.with_index do |row, index|
|
|
47
|
+
pairs = rows[index] || []
|
|
48
|
+
if pairs.length > 4096 || pairs.length > budget || pairs.sum { |_, text| text.bytesize } > BackgroundAnalysis::LINE_LIMIT
|
|
49
|
+
local = row - request.fetch("base_line")
|
|
50
|
+
plain = local < buffer.line_count ? BackgroundAnalysis.line(buffer.rope, local) : ""
|
|
51
|
+
values = [["Text", plain]]
|
|
52
|
+
else
|
|
53
|
+
values = pairs.map { |token, text| [token.qualname, text] }
|
|
54
|
+
end
|
|
55
|
+
budget -= values.length
|
|
56
|
+
[row, values]
|
|
57
|
+
end
|
|
58
|
+
syntax = analyze(document, request.fetch("base"), request.fetch("complete")) if request.fetch("syntax")
|
|
59
|
+
complete = request.fetch("complete") && (!syntax || !syntax.delete("truncated"))
|
|
60
|
+
{"tokens" => tokens, "syntax" => syntax, "complete" => complete}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.update(buffer, previous, source)
|
|
64
|
+
first, shared = 0, [previous.bytesize, source.bytesize].min
|
|
65
|
+
first += 4096 while first + 4096 <= shared && previous.byteslice(first, 4096) == source.byteslice(first, 4096)
|
|
66
|
+
first += 1 while first < shared && previous.getbyte(first) == source.getbyte(first)
|
|
67
|
+
first -= 1 while first.positive? && previous.getbyte(first)&.&(0xc0) == 0x80
|
|
68
|
+
tail = 0
|
|
69
|
+
tail += 4096 while tail + 4096 <= shared - first && previous.byteslice(previous.bytesize - tail - 4096, 4096) == source.byteslice(source.bytesize - tail - 4096, 4096)
|
|
70
|
+
tail += 1 while tail < shared - first && previous.getbyte(previous.bytesize - tail - 1) == source.getbyte(source.bytesize - tail - 1)
|
|
71
|
+
tail -= 1 while tail.positive? && previous.getbyte(previous.bytesize - tail)&.&(0xc0) == 0x80
|
|
72
|
+
buffer.edit([[first...(previous.bytesize - tail), source.byteslice(first, source.bytesize - first - tail)]])
|
|
73
|
+
buffer.history.clear
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.analyze(document, base, complete)
|
|
77
|
+
all_symbols = document.outline
|
|
78
|
+
outline = all_symbols.first(MAX_SYMBOLS).map do |symbol|
|
|
79
|
+
[symbol.name, symbol.kind.to_s, base + symbol.range.begin, base + symbol.range.end,
|
|
80
|
+
base + symbol.selection.begin, base + symbol.selection.end, symbol.depth]
|
|
81
|
+
end
|
|
82
|
+
diagnostics = complete ? document.diagnostics.first(1000).map { |item| [base + item[:range].begin, base + item[:range].end, item[:message]] } : []
|
|
83
|
+
all_brackets = document.__send__(:bracket_ranges).values.uniq
|
|
84
|
+
brackets = all_brackets.first(MAX_BRACKETS).map { |range| [base + range.begin, base + range.end] }
|
|
85
|
+
folds = document.fold_ranges.first(MAX_SYMBOLS).map { |range| [base + range.begin, base + range.end] }
|
|
86
|
+
{"outline" => outline, "diagnostics" => diagnostics, "brackets" => brackets, "folds" => folds,
|
|
87
|
+
"truncated" => all_symbols.length > MAX_SYMBOLS || all_brackets.length > MAX_BRACKETS || (complete && document.diagnostics.length > 1000)}
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "language/definition"
|
|
4
|
+
require_relative "language/symbol"
|
|
5
|
+
|
|
6
|
+
module Canopus
|
|
7
|
+
module Language
|
|
8
|
+
DEFINITIONS = [
|
|
9
|
+
["ruby", "ruby", %w[.rb .rake .gemspec Gemfile Rakefile], "#", /(?:^\s*(?:class|module|def|if|unless|case|while|until|for|begin)\b.*|(?:\bdo|[\[{(])(?:\s*\|[^|]*\|)?\s*)$/, /^\s*(?:end\b|else\b|elsif\b|rescue\b|ensure\b|[\]})])/, [["ruby-lsp"]]],
|
|
10
|
+
["javascript", "javascript", %w[.js .jsx .mjs .cjs], "//", /[\[{(]\s*$/, /^\s*[\]})]/, [["typescript-language-server", "--stdio"]]],
|
|
11
|
+
["typescript", "typescript", %w[.ts .tsx], "//", /[\[{(]\s*$/, /^\s*[\]})]/, [["typescript-language-server", "--stdio"]]],
|
|
12
|
+
["python", "python", %w[.py .pyi], "#", /:\s*(?:#.*)?$/, /^\s*(?:else|elif|except|finally)\b/, [["pyright-langserver", "--stdio"]]],
|
|
13
|
+
["rust", "rust", %w[.rs], "//", /[\[{(]\s*$/, /^\s*[\]})]/, [["rust-analyzer"]]],
|
|
14
|
+
["go", "go", %w[.go], "//", /[\[{(]\s*$/, /^\s*[\]})]/, [["gopls"]]],
|
|
15
|
+
["c", "c", %w[.c .h], "//", /[\[{(]\s*$/, /^\s*[\]})]/, [["clangd"]]],
|
|
16
|
+
["cpp", "cpp", %w[.cpp .hpp .cc .cxx], "//", /[\[{(]\s*$/, /^\s*[\]})]/, [["clangd"]]],
|
|
17
|
+
["json", "json", %w[.json .jsonc], "//", /[\[{]\s*$/, /^\s*[\]}]/, []],
|
|
18
|
+
["yaml", "yaml", %w[.yml .yaml], "#", /:\s*$/, /^\s*$/, []],
|
|
19
|
+
["toml", "toml", %w[.toml], "#", /[\[{]\s*$/, /^\s*[\]}]/, []],
|
|
20
|
+
["markdown", "markdown", %w[.md .markdown], "<!--", /\A\z/, /\A\z/, []],
|
|
21
|
+
["html", "html", %w[.html .erb], "<!--", /<[^\/>]+>\s*$/, /^\s*<\//, []],
|
|
22
|
+
["css", "css", %w[.css .scss], "/*", /\{\s*$/, /^\s*}/, []],
|
|
23
|
+
["shellscript", "shell", %w[.sh .bash .zsh], "#", /\b(?:then|do|case)\s*$/, /^\s*(?:fi|done|esac)\b/, []]
|
|
24
|
+
].map { |values| Definition.new(*values) }.freeze
|
|
25
|
+
PLAIN = Definition.new("text", "plaintext", [], "#", /\A\z/, /\A\z/, [])
|
|
26
|
+
def self.for_path(path)
|
|
27
|
+
return PLAIN unless path
|
|
28
|
+
extension, base = File.extname(path), File.basename(path)
|
|
29
|
+
DEFINITIONS.find { |language| language.extensions.include?(extension) || language.extensions.include?(base) } || PLAIN
|
|
30
|
+
end
|
|
31
|
+
def self.executable?(name)
|
|
32
|
+
paths = name.include?(File::SEPARATOR) ? [name] : ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).map { |path| File.join(path, name) }
|
|
33
|
+
paths.any? { |path| File.file?(path) && File.executable?(path) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
require_relative "language/background_analysis"
|
|
40
|
+
require_relative "language/document"
|
|
41
|
+
Canopus::Language.send(:private_constant, :BackgroundAnalysis)
|
|
42
|
+
require_relative "snippet"
|