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,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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Language
5
+ Definition = Data.define(:name, :lexer, :extensions, :comment, :indent_open, :indent_close, :servers)
6
+ end
7
+ end
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Language
5
+ Symbol = Data.define(:name, :kind, :range, :selection, :depth)
6
+ end
7
+ 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"