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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +191 -19
- data/assets/keymaps/emacs.jsonc +5 -0
- data/assets/keymaps/jetbrains.jsonc +5 -0
- data/assets/keymaps/sublime.jsonc +5 -0
- data/assets/keymaps/vscode.jsonc +5 -0
- data/docs/debugging.md +143 -0
- data/docs/distribution.md +40 -9
- data/docs/lsp.md +131 -0
- data/docs/performance.md +18 -0
- data/docs/providers.md +36 -0
- data/docs/release-log.md +12 -0
- data/docs/tasks.md +64 -0
- data/docs/testing.md +49 -0
- data/docs/vim.md +3 -0
- data/exe/canopus +4 -1
- data/lib/canopus/buffer.rb +291 -50
- data/lib/canopus/cli.rb +5 -2
- data/lib/canopus/command.rb +13 -4
- data/lib/canopus/controller.rb +183 -40
- data/lib/canopus/debug/breakpoints.rb +550 -0
- data/lib/canopus/debug/configuration.rb +291 -0
- data/lib/canopus/debug/console.rb +243 -0
- data/lib/canopus/debug/panel.rb +487 -0
- data/lib/canopus/debug/session.rb +354 -0
- data/lib/canopus/decoration.rb +4 -2
- data/lib/canopus/denebola_compat.rb +27 -0
- data/lib/canopus/diagnostics.rb +173 -0
- data/lib/canopus/display_map/overlay_map.rb +6 -2
- data/lib/canopus/editor/snippet_expandable.rb +3 -2
- data/lib/canopus/editor.rb +122 -31
- data/lib/canopus/git/state.rb +100 -0
- data/lib/canopus/language/background_analysis.rb +93 -14
- data/lib/canopus/language/document.rb +70 -34
- data/lib/canopus/language/symbol.rb +1 -0
- data/lib/canopus/language/syntax_worker.rb +61 -9
- data/lib/canopus/language.rb +3 -0
- data/lib/canopus/minimap.rb +201 -0
- data/lib/canopus/patch.rb +3 -1
- data/lib/canopus/plugins/api.rb +4 -2
- data/lib/canopus/plugins/isolated_runtime.rb +64 -7
- data/lib/canopus/plugins/registry.rb +114 -3
- data/lib/canopus/plugins.rb +8 -7
- data/lib/canopus/project.rb +15 -6
- data/lib/canopus/provider.rb +192 -0
- data/lib/canopus/regexp_compat.rb +36 -4
- data/lib/canopus/selection.rb +12 -0
- data/lib/canopus/settings/schema.rb +201 -0
- data/lib/canopus/settings.rb +328 -49
- data/lib/canopus/task/configuration.rb +356 -0
- data/lib/canopus/task/problem_matcher.rb +264 -0
- data/lib/canopus/task/runner.rb +314 -0
- data/lib/canopus/test_runner/bounded_ignore.rb +144 -0
- data/lib/canopus/test_runner/discovery.rb +97 -0
- data/lib/canopus/test_runner/execution.rb +194 -0
- data/lib/canopus/test_runner/minitest.rb +116 -0
- data/lib/canopus/test_runner/rspec.rb +103 -0
- data/lib/canopus/test_runner/safe_walker.rb +37 -0
- data/lib/canopus/test_runner.rb +54 -0
- data/lib/canopus/theme/import.rb +131 -0
- data/lib/canopus/theme.rb +28 -4
- data/lib/canopus/version.rb +1 -1
- data/lib/canopus/vim/motionable.rb +1 -1
- data/lib/canopus/vim.rb +10 -16
- data/lib/canopus/workspace/auto_savable.rb +83 -0
- data/lib/canopus/workspace/debug_aware.rb +600 -0
- data/lib/canopus/workspace/edit/plan.rb +2 -2
- data/lib/canopus/workspace/editor_configurable.rb +113 -0
- data/lib/canopus/workspace/file_change_aware.rb +11 -3
- data/lib/canopus/workspace/git_aware.rb +80 -34
- data/lib/canopus/workspace/git_blame.rb +175 -0
- data/lib/canopus/workspace/git_conflict_resolution.rb +410 -0
- data/lib/canopus/workspace/git_diff_view.rb +641 -0
- data/lib/canopus/workspace/git_history.rb +171 -0
- data/lib/canopus/workspace/git_remote.rb +267 -0
- data/lib/canopus/workspace/git_staging.rb +583 -0
- data/lib/canopus/workspace/hierarchy_aware.rb +477 -0
- data/lib/canopus/workspace/keymap_aware.rb +66 -0
- data/lib/canopus/workspace/language_aware.rb +3721 -135
- data/lib/canopus/workspace/language_server_configurable.rb +287 -71
- data/lib/canopus/workspace/persistent_undo.rb +169 -0
- data/lib/canopus/workspace/problems_aware.rb +131 -0
- data/lib/canopus/workspace/project_searchable.rb +88 -11
- data/lib/canopus/workspace/project_tree_editable.rb +3 -1
- data/lib/canopus/workspace/recoverable.rb +204 -0
- data/lib/canopus/workspace/session_persistable.rb +85 -13
- data/lib/canopus/workspace/settings_aware.rb +76 -2
- data/lib/canopus/workspace/task_aware.rb +194 -0
- data/lib/canopus/workspace/test_aware.rb +394 -0
- data/lib/canopus/workspace/trust.rb +73 -0
- data/lib/canopus/workspace/view/terminal_presentable.rb +298 -47
- data/lib/canopus/workspace/view.rb +399 -74
- data/lib/canopus/workspace.rb +688 -44
- data/lib/canopus.rb +20 -2
- data/sig/canopus.rbs +138 -31
- data/sig/controller.rbs +10 -3
- data/sig/debug.rbs +121 -0
- data/sig/decoration.rbs +12 -2
- data/sig/diagnostics.rbs +26 -0
- data/sig/minimap.rbs +20 -0
- data/sig/provider.rbs +29 -0
- data/sig/services.rbs +1 -16
- data/sig/task.rbs +92 -0
- data/sig/test_runner.rbs +109 -0
- data/sig/trust.rbs +17 -0
- data/sig/workspace_edits.rbs +2 -2
- data/sig/workspace_services.rbs +234 -2
- data/tools/check_dependencies.rb +21 -2
- data/tools/package.rb +172 -14
- data/tools/package_signature_test.rb +33 -0
- data/tools/package_test.rb +29 -0
- data/tools/sign_package.rb +123 -0
- data/tools/update.rb +189 -0
- data/tools/update_test.rb +71 -0
- metadata +206 -31
- data/lib/canopus/lsp/client.rb +0 -307
- data/lib/canopus/lsp/error.rb +0 -7
- data/lib/canopus/lsp/future/subscription.rb +0 -9
- data/lib/canopus/lsp/future.rb +0 -87
- data/lib/canopus/lsp/protocol.rb +0 -83
- data/lib/canopus/lsp/server_error.rb +0 -13
- data/lib/canopus/lsp/timeout.rb +0 -7
- data/lib/canopus/lsp/transport.rb +0 -123
- data/lib/canopus/lsp.rb +0 -19
- data/lib/canopus/project/search.rb +0 -164
- data/lib/canopus/project/search_worker/cancelled.rb +0 -3
- data/lib/canopus/project/search_worker/runner.rb +0 -9
- data/lib/canopus/project/search_worker.rb +0 -108
- data/lib/canopus/terminal/cell.rb +0 -7
- data/lib/canopus/terminal/grid.rb +0 -321
- data/lib/canopus/terminal/pty.rb +0 -178
- data/lib/canopus/terminal/scrollback.rb +0 -38
- data/lib/canopus/terminal/vt.rb +0 -398
- data/lib/canopus/terminal.rb +0 -13
- data/sig/lsp.rbs +0 -99
- data/sig/terminal.rbs +0 -130
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
canopus_root = File.expand_path("../../..", __dir__)
|
|
4
|
+
if defined?(Gem::Specification)
|
|
5
|
+
Gem::Specification.find_all_by_name("canopus")
|
|
6
|
+
.find { |specification| File.expand_path(specification.full_gem_path) == canopus_root }&.activate
|
|
7
|
+
end
|
|
8
|
+
antares_path = ENV["ANTARES_PATH"]
|
|
9
|
+
antares_path ? require(File.expand_path("lib/antares", File.expand_path(antares_path, canopus_root))) : require("antares")
|
|
4
10
|
require_relative "../../canopus"
|
|
5
11
|
|
|
6
12
|
module Canopus
|
|
@@ -11,6 +17,8 @@ module Canopus
|
|
|
11
17
|
MAX_CACHE_BYTES = 4 << 20
|
|
12
18
|
MAX_SYMBOLS = 10_000
|
|
13
19
|
MAX_BRACKETS = 8192
|
|
20
|
+
MAX_SELECTIONS = 256
|
|
21
|
+
MAX_SELECTION_RANGES = 10_000
|
|
14
22
|
|
|
15
23
|
def self.call(request)
|
|
16
24
|
source = request.fetch("source")
|
|
@@ -41,7 +49,8 @@ module Canopus
|
|
|
41
49
|
first = request.fetch("first") - request.fetch("base_line")
|
|
42
50
|
last = [request.fetch("last") - request.fetch("base_line"), buffer.line_count - 1].min
|
|
43
51
|
highlighter = document.highlighter(strategy: request.fetch("complete") ? nil : :window,
|
|
44
|
-
window_context: request.fetch("complete") ? nil : request.fetch("context")
|
|
52
|
+
window_context: request.fetch("complete") ? nil : request.fetch("context"),
|
|
53
|
+
max_seconds: request.fetch("complete") ? 2 : nil)
|
|
45
54
|
rows = first <= last ? highlighter.tokens_in(first..last) : []
|
|
46
55
|
budget = 65_536
|
|
47
56
|
tokens = (request.fetch("first")..request.fetch("last")).map.with_index do |row, index|
|
|
@@ -56,9 +65,10 @@ module Canopus
|
|
|
56
65
|
budget -= values.length
|
|
57
66
|
[row, values]
|
|
58
67
|
end
|
|
59
|
-
|
|
68
|
+
selections = selection_ranges(document, buffer, request)
|
|
69
|
+
syntax = analyze(document, request.fetch("base"), request.fetch("base_line"), request.fetch("complete")) if request.fetch("syntax")
|
|
60
70
|
complete = request.fetch("complete") && (!syntax || !syntax.delete("truncated"))
|
|
61
|
-
{"tokens" => tokens, "syntax" => syntax, "complete" => complete}
|
|
71
|
+
{"tokens" => tokens, "syntax" => syntax, "selections" => selections, "complete" => complete}
|
|
62
72
|
end
|
|
63
73
|
|
|
64
74
|
def self.update(buffer, previous, source)
|
|
@@ -74,18 +84,60 @@ module Canopus
|
|
|
74
84
|
buffer.history.clear
|
|
75
85
|
end
|
|
76
86
|
|
|
77
|
-
def self.
|
|
87
|
+
def self.selection_ranges(document, buffer, request)
|
|
88
|
+
positions = request.fetch("selections", [])
|
|
89
|
+
seen = {}
|
|
90
|
+
unless positions.is_a?(Array) && positions.length <= MAX_SELECTIONS && positions.all? do |value|
|
|
91
|
+
value.is_a?(Array) && value.length == 3 && value[0].is_a?(Integer) && value[0].between?(0, MAX_SELECTIONS - 1) &&
|
|
92
|
+
!seen[value[0]] && (seen[value[0]] = true) && value.drop(1).all? { |item| item.is_a?(Integer) && item >= 0 }
|
|
93
|
+
end
|
|
94
|
+
raise ArgumentError, "invalid selection positions"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
total = 0
|
|
98
|
+
positions.map do |index, row, column|
|
|
99
|
+
local_row = row - request.fetch("base_line")
|
|
100
|
+
point = Denebola::Point.new(local_row, column)
|
|
101
|
+
cursor = buffer.rope.offset_at(point)
|
|
102
|
+
candidates = document.highlighter.structure.selection_ranges(local_row, column).map do |region|
|
|
103
|
+
first = buffer.rope.offset_at(Denebola::Point.new(region.start_line, region.start_column || 0))
|
|
104
|
+
last = buffer.rope.offset_at(Denebola::Point.new(region.end_line,
|
|
105
|
+
region.end_column || buffer.line(region.end_line).length))
|
|
106
|
+
[first, last]
|
|
107
|
+
end.uniq.sort_by { |first, last| [last - first, -first] }
|
|
108
|
+
ranges = candidates.each_with_object([]) do |(first, last), nested|
|
|
109
|
+
next unless first <= cursor && cursor <= last
|
|
110
|
+
next if nested.last && !(first <= nested.last[0] && nested.last[1] <= last)
|
|
111
|
+
total += 1
|
|
112
|
+
raise Error, "too many Antares selection ranges" if total > MAX_SELECTION_RANGES
|
|
113
|
+
nested << [first, last]
|
|
114
|
+
end
|
|
115
|
+
raise Error, "Antares selection range is too deep" if ranges.length > MAX_SELECTIONS
|
|
116
|
+
[index, ranges.map { |first, last| [request.fetch("base") + first, request.fetch("base") + last] }]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.analyze(document, base, base_line, complete)
|
|
78
121
|
all_symbols = document.outline
|
|
79
122
|
outline = all_symbols.first(MAX_SYMBOLS).map do |symbol|
|
|
80
123
|
[symbol.name, symbol.kind.to_s, base + symbol.range.begin, base + symbol.range.end,
|
|
81
124
|
base + symbol.selection.begin, base + symbol.selection.end, symbol.depth]
|
|
82
125
|
end
|
|
83
126
|
diagnostics = complete ? document.diagnostics.first(1000).map { |item| [base + item[:range].begin, base + item[:range].end, item[:message]] } : []
|
|
84
|
-
all_brackets = document.
|
|
85
|
-
brackets = all_brackets.first(MAX_BRACKETS).map
|
|
127
|
+
all_brackets = document.brackets
|
|
128
|
+
brackets = all_brackets.first(MAX_BRACKETS).map do |pair|
|
|
129
|
+
[base + pair.open_range.begin, base + pair.open_range.end, base_line + pair.open_row,
|
|
130
|
+
base + pair.close_range.begin, base + pair.close_range.end, base_line + pair.close_row, pair.depth]
|
|
131
|
+
end
|
|
132
|
+
all_regions = document.structure_regions
|
|
133
|
+
structure_regions = all_regions.first(MAX_SYMBOLS).map do |region|
|
|
134
|
+
[base_line + region[:start_line], base_line + region[:end_line], region[:kind].to_s]
|
|
135
|
+
end
|
|
86
136
|
folds = document.fold_ranges.first(MAX_SYMBOLS).map { |range| [base + range.begin, base + range.end] }
|
|
87
|
-
{"outline" => outline, "diagnostics" => diagnostics, "brackets" => brackets,
|
|
88
|
-
"
|
|
137
|
+
{"outline" => outline, "diagnostics" => diagnostics, "brackets" => brackets,
|
|
138
|
+
"structure_regions" => structure_regions, "folds" => folds,
|
|
139
|
+
"truncated" => all_symbols.length > MAX_SYMBOLS || all_brackets.length > MAX_BRACKETS ||
|
|
140
|
+
all_regions.length > MAX_SYMBOLS || (complete && document.diagnostics.length > 1000)}
|
|
89
141
|
end
|
|
90
142
|
end
|
|
91
143
|
end
|
data/lib/canopus/language.rb
CHANGED
|
@@ -5,6 +5,8 @@ require_relative "language/symbol"
|
|
|
5
5
|
|
|
6
6
|
module Canopus
|
|
7
7
|
module Language
|
|
8
|
+
Bracket = Data.define(:open_range, :close_range, :open_row, :close_row, :depth)
|
|
9
|
+
|
|
8
10
|
DEFINITIONS = [
|
|
9
11
|
["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
12
|
["javascript", "javascript", %w[.js .jsx .mjs .cjs], "//", /[\[{(]\s*$/, /^\s*[\]})]/, [["typescript-language-server", "--stdio"]]],
|
|
@@ -19,6 +21,7 @@ module Canopus
|
|
|
19
21
|
["toml", "toml", %w[.toml], "#", /[\[{]\s*$/, /^\s*[\]}]/, []],
|
|
20
22
|
["markdown", "markdown", %w[.md .markdown], "<!--", /\A\z/, /\A\z/, []],
|
|
21
23
|
["html", "html", %w[.html .erb], "<!--", /<[^\/>]+>\s*$/, /^\s*<\//, []],
|
|
24
|
+
["xml", "xml", %w[.xml .xhtml .svg], "<!--", /<[^\/>]+>\s*$/, /^\s*<\//, []],
|
|
22
25
|
["css", "css", %w[.css .scss], "/*", /\{\s*$/, /^\s*}/, []],
|
|
23
26
|
["shellscript", "shell", %w[.sh .bash .zsh], "#", /\b(?:then|do|case)\s*$/, /^\s*(?:fi|done|esac)\b/, []]
|
|
24
27
|
].map { |values| Definition.new(*values) }.freeze
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
# Bounded, edit-aware row textures shared by every pane showing a buffer.
|
|
5
|
+
class Minimap
|
|
6
|
+
GENERATION_LIMIT = 24
|
|
7
|
+
ENTRY_LIMIT = 2_048
|
|
8
|
+
VARIANT_LIMIT = 4
|
|
9
|
+
LINE_BYTES = 4_096
|
|
10
|
+
|
|
11
|
+
attr_reader :generated
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@variants, @subscriptions, @search = {}, {}, {}
|
|
15
|
+
@next_key = @frame = 0
|
|
16
|
+
@generated, @pending = 0, false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def begin_frame(frame, text_system:, font_size:, font_family:, scale_factor:)
|
|
20
|
+
identity = [text_system&.object_id, text_system&.respond_to?(:font) ? text_system.font.object_id : nil,
|
|
21
|
+
font_size, font_family, scale_factor]
|
|
22
|
+
rebuild if @identity && @identity != identity
|
|
23
|
+
@identity, @text_system, @font_size, @scale_factor = identity, text_system, font_size, scale_factor
|
|
24
|
+
return if @frame == frame
|
|
25
|
+
|
|
26
|
+
@frame, @generated, @pending = frame, 0, false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def texture(buffer, row, width:)
|
|
30
|
+
return unless @text_system && row.is_a?(Integer) && row.between?(0, buffer.line_count - 1)
|
|
31
|
+
variant = variant(width)
|
|
32
|
+
pair = [buffer, row]
|
|
33
|
+
if (entry = variant[:entries].delete(pair))
|
|
34
|
+
variant[:entries][pair] = entry
|
|
35
|
+
return variant[:cache].texture(entry[:key], outlines: [])
|
|
36
|
+
end
|
|
37
|
+
unless @generated < GENERATION_LIMIT
|
|
38
|
+
@pending = true
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
attach(buffer)
|
|
43
|
+
source = line_source(buffer.rope, row)
|
|
44
|
+
layout = @text_system.layout_line(source, size: @font_size)
|
|
45
|
+
outlines = []
|
|
46
|
+
layout.glyphs.each do |glyph|
|
|
47
|
+
break if glyph.x * 0.1 >= width
|
|
48
|
+
begin
|
|
49
|
+
factor = @font_size.to_f / glyph.font.units_per_em
|
|
50
|
+
outlines << glyph.font.outline(glyph.id).transform([factor, 0, 0, -factor, glyph.x, layout.ascent])
|
|
51
|
+
rescue StandardError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
key = @next_key += 1
|
|
56
|
+
texture = variant[:cache].texture(key, outlines: outlines)
|
|
57
|
+
variant[:entries][pair] = {key: key, offset: buffer.rope.line_start(row)}
|
|
58
|
+
trim(variant)
|
|
59
|
+
@generated += 1
|
|
60
|
+
texture
|
|
61
|
+
rescue StandardError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def pending? = @pending
|
|
66
|
+
def cache_size = @variants.values.sum { |variant| variant[:entries].length }
|
|
67
|
+
def cached_rows(buffer) = @variants.values.flat_map { |variant| variant[:entries].keys.filter_map { |source, row| row if source.equal?(buffer) } }.uniq.sort
|
|
68
|
+
|
|
69
|
+
def record_search(buffer, version, ranges)
|
|
70
|
+
attach(buffer)
|
|
71
|
+
rows = ranges.first(10_000).filter_map do |range|
|
|
72
|
+
buffer.rope.point_at(range.begin).row if range.is_a?(Range) && range.begin.is_a?(Integer)
|
|
73
|
+
rescue RangeError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
@search[buffer] = {version: version, rows: rows.sort.uniq.freeze}.freeze
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def search_rows(buffer, version)
|
|
80
|
+
entry = @search[buffer]
|
|
81
|
+
entry && entry[:version] == version ? entry[:rows] : []
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def release(buffer)
|
|
85
|
+
@variants.each_value { |variant| remove_buffer(variant, buffer) }
|
|
86
|
+
@subscriptions.delete(buffer)&.detach
|
|
87
|
+
@search.delete(buffer)
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def close
|
|
92
|
+
@subscriptions.each_value(&:detach)
|
|
93
|
+
@subscriptions.clear
|
|
94
|
+
rebuild
|
|
95
|
+
@search.clear
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def variant(width)
|
|
102
|
+
width = Integer(width)
|
|
103
|
+
key = [width, @scale_factor]
|
|
104
|
+
cached = @variants.delete(key)
|
|
105
|
+
return @variants[key] = cached if cached
|
|
106
|
+
|
|
107
|
+
while @variants.length >= VARIANT_LIMIT
|
|
108
|
+
_old_key, old = @variants.shift
|
|
109
|
+
old[:cache].close
|
|
110
|
+
end
|
|
111
|
+
scale = Float(@scale_factor)
|
|
112
|
+
texture_width, texture_height, max_bytes = (width * scale).ceil, (2 * scale).ceil, 8 << 20
|
|
113
|
+
limit = [ENTRY_LIMIT, max_bytes / (texture_width * texture_height)].min.clamp(1, ENTRY_LIMIT)
|
|
114
|
+
cache = Zaniah::TextSystem::LowResolutionTextCache.new(width: texture_width,
|
|
115
|
+
height: texture_height, scale: 0.1 * scale, capacity: limit, max_bytes: max_bytes)
|
|
116
|
+
@variants[key] = {cache: cache, entries: {}, limit: limit}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def rebuild
|
|
120
|
+
@variants.each_value { |variant| variant[:cache].close }
|
|
121
|
+
@variants.clear
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def attach(buffer)
|
|
125
|
+
@subscriptions[buffer] ||= buffer.on_edit { |patch| edited(buffer, patch) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def trim(variant)
|
|
129
|
+
while variant[:entries].length > variant[:limit]
|
|
130
|
+
_pair, entry = variant[:entries].shift
|
|
131
|
+
variant[:cache].invalidate(entry[:key])
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def remove_buffer(variant, buffer)
|
|
136
|
+
variant[:entries].delete_if do |(source, _row), entry|
|
|
137
|
+
next false unless source.equal?(buffer)
|
|
138
|
+
|
|
139
|
+
variant[:cache].invalidate(entry[:key])
|
|
140
|
+
true
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def edited(buffer, patch)
|
|
145
|
+
@search.delete(buffer)
|
|
146
|
+
if patch.is_a?(Patch::Reload)
|
|
147
|
+
@variants.each_value { |variant| remove_buffer(variant, buffer) }
|
|
148
|
+
else
|
|
149
|
+
parts = patch.is_a?(Patch::Composite) ? patch.patches : [patch]
|
|
150
|
+
parts.each do |part|
|
|
151
|
+
if part.is_a?(Patch::Reload)
|
|
152
|
+
@variants.each_value { |variant| remove_buffer(variant, buffer) }
|
|
153
|
+
else
|
|
154
|
+
apply_patch(buffer, part)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def apply_patch(buffer, patch)
|
|
161
|
+
affected = patch.edits.map do |edit|
|
|
162
|
+
patch.before.point_at(edit.old_range.begin).row..patch.before.point_at(edit.old_range.end).row
|
|
163
|
+
end
|
|
164
|
+
@variants.each_value do |variant|
|
|
165
|
+
mapped = {}
|
|
166
|
+
variant[:entries].each do |(source, row), entry|
|
|
167
|
+
unless source.equal?(buffer)
|
|
168
|
+
mapped[[source, row]] = entry
|
|
169
|
+
next
|
|
170
|
+
end
|
|
171
|
+
offset = patch.map_offset(entry[:offset], bias: :right)
|
|
172
|
+
new_row = patch.after.point_at(offset).row
|
|
173
|
+
changed = affected.any? { |range| range.cover?(row) }
|
|
174
|
+
aligned = patch.after.line_start(new_row) == offset
|
|
175
|
+
same_line = !changed || line_source(patch.before, row) == line_source(patch.after, new_row)
|
|
176
|
+
unless aligned && same_line && !mapped.key?([buffer, new_row])
|
|
177
|
+
variant[:cache].invalidate(entry[:key])
|
|
178
|
+
next
|
|
179
|
+
end
|
|
180
|
+
mapped[[buffer, new_row]] = {key: entry[:key], offset: offset}
|
|
181
|
+
end
|
|
182
|
+
variant[:entries] = mapped
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def line_source(rope, row)
|
|
187
|
+
return rope.line_window(row, max_bytes: LINE_BYTES).first if rope.respond_to?(:line_window)
|
|
188
|
+
|
|
189
|
+
first = rope.line_start(row)
|
|
190
|
+
last = row + 1 < rope.line_count ? rope.line_start(row + 1) : rope.bytesize
|
|
191
|
+
ending = [first + LINE_BYTES, last].min
|
|
192
|
+
begin
|
|
193
|
+
rope.point_at(ending)
|
|
194
|
+
rescue RangeError
|
|
195
|
+
ending -= 1
|
|
196
|
+
retry
|
|
197
|
+
end
|
|
198
|
+
rope.byteslice(first, ending - first).to_s.sub(/(?:\r\n|[\r\n\u2028\u2029])\z/, "")
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
data/lib/canopus/patch.rb
CHANGED
|
@@ -7,7 +7,9 @@ module Canopus
|
|
|
7
7
|
def initialize(before, after, changes)
|
|
8
8
|
@before, @after = before, after
|
|
9
9
|
delta = 0
|
|
10
|
-
@edits = changes.sort_by
|
|
10
|
+
@edits = changes.each_with_index.sort_by do |((range, _), index)|
|
|
11
|
+
[range.begin, range.end + (range.exclude_end? ? 0 : 1), index]
|
|
12
|
+
end.map do |((range, text), _)|
|
|
11
13
|
ending = range.end + (range.exclude_end? ? 0 : 1)
|
|
12
14
|
old = range.begin...ending
|
|
13
15
|
start = range.begin + delta
|
data/lib/canopus/plugins/api.rb
CHANGED
|
@@ -9,7 +9,9 @@ module Canopus
|
|
|
9
9
|
@workspace, @permissions = workspace, permissions
|
|
10
10
|
end
|
|
11
11
|
def permit!(permission)
|
|
12
|
-
|
|
12
|
+
requested = permission.to_s
|
|
13
|
+
normalized = requested == "process" ? "exec" : requested
|
|
14
|
+
raise Canopus::Plugins::PermissionDenied, "plugin requires #{requested}" unless @permissions.include?(normalized)
|
|
13
15
|
end
|
|
14
16
|
def text
|
|
15
17
|
permit!("read_buffer")
|
|
@@ -25,7 +27,7 @@ module Canopus
|
|
|
25
27
|
end
|
|
26
28
|
def notify(message) = @workspace.message = message.to_s
|
|
27
29
|
def run(command)
|
|
28
|
-
permit!("
|
|
30
|
+
permit!("exec")
|
|
29
31
|
raise ArgumentError, "command must be a nonempty argument array" unless command.is_a?(Array) && !command.empty?
|
|
30
32
|
Open3.capture3(*command, chdir: @workspace.root)
|
|
31
33
|
end
|
|
@@ -5,7 +5,7 @@ require "open3"
|
|
|
5
5
|
require "rbconfig"
|
|
6
6
|
|
|
7
7
|
# Separate-process execution isolates crashes and accidental global state.
|
|
8
|
-
#
|
|
8
|
+
# Saiph adds an OS sandbox when the host backend supports the requested policy.
|
|
9
9
|
module Canopus
|
|
10
10
|
module Plugins
|
|
11
11
|
class IsolatedRuntime
|
|
@@ -21,7 +21,8 @@ module Canopus
|
|
|
21
21
|
@context, @permissions, @edits, @messages = context, permissions, [], []
|
|
22
22
|
end
|
|
23
23
|
def permit!(name)
|
|
24
|
-
|
|
24
|
+
normalized = name.to_s == "process" ? "exec" : name.to_s
|
|
25
|
+
raise "plugin requires #{name}" unless @permissions.include?(normalized)
|
|
25
26
|
end
|
|
26
27
|
def text
|
|
27
28
|
permit!("read_buffer")
|
|
@@ -37,7 +38,7 @@ module Canopus
|
|
|
37
38
|
end
|
|
38
39
|
def notify(message) = @messages << message.to_s
|
|
39
40
|
def run(command)
|
|
40
|
-
permit!("
|
|
41
|
+
permit!("exec")
|
|
41
42
|
raise "command must be an argument array" unless command.is_a?(Array) && !command.empty?
|
|
42
43
|
Open3.capture3(*command, chdir: @context.fetch("root"))
|
|
43
44
|
end
|
|
@@ -95,12 +96,12 @@ module Canopus
|
|
|
95
96
|
RUBY
|
|
96
97
|
|
|
97
98
|
def initialize(workspace, source, path, permissions, timeout: 2)
|
|
98
|
-
@workspace, @permissions, @timeout = workspace, permissions, timeout
|
|
99
|
-
|
|
99
|
+
@workspace, @permissions, @timeout = workspace, permissions.map { |permission| permission.to_s == "process" ? "exec" : permission.to_s }.uniq, timeout
|
|
100
|
+
start_process
|
|
100
101
|
@input.sync = true
|
|
101
102
|
@output.binmode
|
|
102
103
|
@pending, @lock = +"".b, Mutex.new
|
|
103
|
-
@input.puts(JSON.generate(source: source, path: path, permissions: permissions))
|
|
104
|
+
@input.puts(JSON.generate(source: source, path: path, permissions: @permissions))
|
|
104
105
|
manifest = response
|
|
105
106
|
manifest.fetch("actions").each do |name, description|
|
|
106
107
|
workspace.register_action(name, description: description) { invoke(:action, name) }
|
|
@@ -138,12 +139,68 @@ module Canopus
|
|
|
138
139
|
def close
|
|
139
140
|
@input&.close unless @input&.closed?
|
|
140
141
|
if @process && !@process.join(0.2)
|
|
141
|
-
Process.kill("KILL", @
|
|
142
|
+
Process.kill("KILL", @pid) rescue Errno::ESRCH
|
|
142
143
|
@process.join
|
|
143
144
|
end
|
|
144
145
|
@output&.close unless @output&.closed?
|
|
145
146
|
end
|
|
146
147
|
private
|
|
148
|
+
def start_process
|
|
149
|
+
command = [RbConfig.ruby, "-e", WORKER]
|
|
150
|
+
mode = @workspace.settings["plugins"].fetch("sandbox", "auto")
|
|
151
|
+
return start_open3(command) if mode == "off"
|
|
152
|
+
|
|
153
|
+
start_saiph(command)
|
|
154
|
+
rescue StandardError => error
|
|
155
|
+
raise unless defined?(Saiph::Unsupported) && error.is_a?(Saiph::Unsupported)
|
|
156
|
+
|
|
157
|
+
raise Canopus::Error, "plugin sandbox is required: #{error.message}" if mode == "required"
|
|
158
|
+
|
|
159
|
+
@workspace.message = "Plugin OS sandbox disabled: #{error.message}"
|
|
160
|
+
start_open3(command)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def start_open3(command)
|
|
164
|
+
@input, @output, @process = Open3.popen2(*command)
|
|
165
|
+
@pid = @process.pid
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def start_saiph(command)
|
|
169
|
+
load_saiph
|
|
170
|
+
child_input, input = IO.pipe
|
|
171
|
+
output, child_output = IO.pipe
|
|
172
|
+
@pid = Saiph.spawn(command, policy: sandbox_policy, in: child_input, out: child_output)
|
|
173
|
+
child_input.close
|
|
174
|
+
child_output.close
|
|
175
|
+
@input, @output = input, output
|
|
176
|
+
@process = Process.detach(@pid)
|
|
177
|
+
rescue Exception
|
|
178
|
+
child_input&.close unless child_input&.closed?
|
|
179
|
+
child_output&.close unless child_output&.closed?
|
|
180
|
+
input&.close unless input&.closed?
|
|
181
|
+
output&.close unless output&.closed?
|
|
182
|
+
raise
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def sandbox_policy
|
|
186
|
+
load_saiph
|
|
187
|
+
Saiph::Policy.new(
|
|
188
|
+
@permissions.include?("read_project") ? [@workspace.root] : [],
|
|
189
|
+
@permissions.include?("write_project") ? [@workspace.root] : [],
|
|
190
|
+
@permissions.include?("network"),
|
|
191
|
+
@permissions.include?("exec"),
|
|
192
|
+
[]
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def load_saiph
|
|
197
|
+
return if defined?(Saiph::Policy)
|
|
198
|
+
|
|
199
|
+
saiph_path = ENV["SAIPH_PATH"]
|
|
200
|
+
saiph_root = File.expand_path("../..", __dir__)
|
|
201
|
+
saiph_path ? require(File.expand_path("lib/saiph", File.expand_path(saiph_path, saiph_root))) : require("saiph")
|
|
202
|
+
end
|
|
203
|
+
|
|
147
204
|
def response
|
|
148
205
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
|
|
149
206
|
loop do
|
|
@@ -1,17 +1,87 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
3
7
|
module Canopus
|
|
4
8
|
module Plugins
|
|
5
9
|
class Registry
|
|
6
|
-
|
|
10
|
+
API_VERSION = "1"
|
|
11
|
+
NAME_PATTERN = /\Acanopus-plugin-[a-z0-9][a-z0-9-]*\z/
|
|
12
|
+
PERMISSIONS = Plugins::PERMISSIONS
|
|
7
13
|
|
|
8
|
-
def initialize(workspace)
|
|
14
|
+
def initialize(workspace, state_path: nil)
|
|
9
15
|
@workspace, @loaded = workspace, []
|
|
16
|
+
@state_path = state_path || File.join(ENV["XDG_STATE_HOME"] || File.expand_path("~/.local/state"), "canopus", "plugins.json")
|
|
17
|
+
@disabled = load_state
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def search(query, limit: 20)
|
|
21
|
+
require "rubygems/spec_fetcher"
|
|
22
|
+
query = String(query)
|
|
23
|
+
raise ArgumentError, "plugin search query must not be empty" if query.strip.empty?
|
|
24
|
+
raise ArgumentError, "invalid plugin search limit" unless limit.is_a?(Integer) && limit.between?(1, 100)
|
|
25
|
+
|
|
26
|
+
dependency = Gem::Dependency.new(query.start_with?("canopus-plugin-") ? query : "canopus-plugin-#{query}", Gem::Requirement.default)
|
|
27
|
+
Gem::SpecFetcher.fetcher.search_for_dependency(dependency).first.filter_map do |entry, _source|
|
|
28
|
+
spec = entry.is_a?(Array) ? entry.first : entry
|
|
29
|
+
spec = spec.spec if spec.respond_to?(:spec)
|
|
30
|
+
name = spec.respond_to?(:name) ? spec.name : nil
|
|
31
|
+
next unless plugin_name?(name)
|
|
32
|
+
{name: name, version: spec.respond_to?(:version) ? spec.version.to_s : "", summary: spec.respond_to?(:summary) ? spec.summary.to_s : "",
|
|
33
|
+
api_version: spec.respond_to?(:metadata) ? spec.metadata["canopus_plugin_api_version"] : nil}
|
|
34
|
+
end.uniq { |plugin| plugin[:name] }.first(limit)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def install(name, version: nil)
|
|
38
|
+
require "rubygems/dependency_installer"
|
|
39
|
+
name = validate_name(name)
|
|
40
|
+
specifications = Gem::DependencyInstaller.new.install(name, version || Gem::Requirement.default)
|
|
41
|
+
specification = specifications.reverse.find { |candidate| candidate.name == name }
|
|
42
|
+
validate_specification!(specification)
|
|
43
|
+
specification
|
|
44
|
+
rescue Gem::Exception => error
|
|
45
|
+
raise Canopus::Error, "plugin install failed: #{error.message}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def update(name)
|
|
49
|
+
install(name)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def disable(name)
|
|
53
|
+
name = validate_name(name)
|
|
54
|
+
@disabled[name] = true
|
|
55
|
+
save_state
|
|
56
|
+
true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def enable(name)
|
|
60
|
+
name = validate_name(name)
|
|
61
|
+
@disabled.delete(name)
|
|
62
|
+
save_state
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def disabled?(name) = @disabled.key?(validate_name(name))
|
|
67
|
+
|
|
68
|
+
def load_gem(name, trusted: false, permissions: [], isolated: true, timeout: 2)
|
|
69
|
+
name = validate_name(name)
|
|
70
|
+
raise Canopus::Error, "plugin is disabled: #{name}" if disabled?(name)
|
|
71
|
+
specification = validate_specification!(Gem::Specification.find_all_by_name(name).max_by(&:version))
|
|
72
|
+
entrypoint = specification.metadata["canopus_plugin_entrypoint"] || "lib/#{name}.rb"
|
|
73
|
+
path = File.realpath(File.expand_path(entrypoint, specification.full_gem_path))
|
|
74
|
+
prefix = File.realpath(specification.full_gem_path) + File::SEPARATOR
|
|
75
|
+
raise Canopus::Error, "plugin entrypoint escapes gem directory" unless path.start_with?(prefix)
|
|
76
|
+
|
|
77
|
+
load(path, trusted: trusted, permissions: permissions, isolated: isolated, timeout: timeout)
|
|
78
|
+
rescue Errno::ENOENT
|
|
79
|
+
raise Canopus::Error, "plugin entrypoint not found: #{name}"
|
|
10
80
|
end
|
|
11
81
|
|
|
12
82
|
def load(path, trusted: false, permissions: [], isolated: true, timeout: 2)
|
|
13
83
|
raise PermissionDenied, "plugins execute Ruby code; explicitly mark this plugin trusted" unless trusted
|
|
14
|
-
permissions = permissions.map
|
|
84
|
+
permissions = permissions.map { |permission| permission.to_s == "process" ? "exec" : permission.to_s }.uniq
|
|
15
85
|
raise PermissionDenied, "unknown plugin permission" unless (permissions - PERMISSIONS).empty?
|
|
16
86
|
source = File.read(path, 256 * 1024 + 1, encoding: "UTF-8")
|
|
17
87
|
raise Error, "plugin source exceeds 256KB" if source.bytesize > 256 * 1024
|
|
@@ -25,6 +95,47 @@ module Canopus
|
|
|
25
95
|
end
|
|
26
96
|
|
|
27
97
|
def close = @loaded.each(&:close)
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def plugin_name?(name) = NAME_PATTERN.match?(name.to_s)
|
|
102
|
+
|
|
103
|
+
def validate_name(name)
|
|
104
|
+
name = String(name)
|
|
105
|
+
raise ArgumentError, "plugin gem name must match canopus-plugin-*" unless plugin_name?(name)
|
|
106
|
+
name
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def validate_specification!(specification)
|
|
110
|
+
raise Canopus::Error, "plugin gem was not found" unless specification
|
|
111
|
+
unless plugin_name?(specification.name)
|
|
112
|
+
raise Canopus::Error, "plugin gem name must match canopus-plugin-*"
|
|
113
|
+
end
|
|
114
|
+
version = specification.metadata["canopus_plugin_api_version"]
|
|
115
|
+
raise Canopus::Error, "unsupported plugin API version" unless version == API_VERSION
|
|
116
|
+
specification
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def load_state
|
|
120
|
+
return {} unless File.file?(@state_path)
|
|
121
|
+
value = JSON.parse(File.read(@state_path))
|
|
122
|
+
value.is_a?(Hash) ? value.select { |name, disabled| plugin_name?(name) && disabled == true } : {}
|
|
123
|
+
rescue JSON::ParserError, Errno::ENOENT, Errno::EACCES
|
|
124
|
+
{}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def save_state
|
|
128
|
+
FileUtils.mkdir_p(File.dirname(@state_path))
|
|
129
|
+
Tempfile.create([".plugins-", ".json"], File.dirname(@state_path)) do |file|
|
|
130
|
+
file.write(JSON.generate(@disabled))
|
|
131
|
+
file.flush
|
|
132
|
+
file.fsync
|
|
133
|
+
file.close
|
|
134
|
+
File.rename(file.path, @state_path)
|
|
135
|
+
end
|
|
136
|
+
rescue SystemCallError => error
|
|
137
|
+
raise Canopus::Error, "cannot save plugin state: #{error.message}"
|
|
138
|
+
end
|
|
28
139
|
end
|
|
29
140
|
end
|
|
30
141
|
end
|
data/lib/canopus/plugins.rb
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Canopus
|
|
4
|
-
module Plugins
|
|
4
|
+
module Plugins
|
|
5
|
+
PERMISSIONS = %w[read_buffer edit_buffer read_project write_project exec process network].freeze
|
|
6
|
+
autoload :PermissionDenied, "canopus/plugins/permission_denied"
|
|
7
|
+
autoload :API, "canopus/plugins/api"
|
|
8
|
+
autoload :LocalRuntime, "canopus/plugins/local_runtime"
|
|
9
|
+
autoload :IsolatedRuntime, "canopus/plugins/isolated_runtime"
|
|
10
|
+
autoload :Registry, "canopus/plugins/registry"
|
|
11
|
+
end
|
|
5
12
|
end
|
|
6
|
-
|
|
7
|
-
require_relative "plugins/permission_denied"
|
|
8
|
-
require_relative "plugins/api"
|
|
9
|
-
require_relative "plugins/local_runtime"
|
|
10
|
-
require_relative "plugins/isolated_runtime"
|
|
11
|
-
require_relative "plugins/registry"
|