canopus 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +191 -19
  4. data/assets/keymaps/emacs.jsonc +5 -0
  5. data/assets/keymaps/jetbrains.jsonc +5 -0
  6. data/assets/keymaps/sublime.jsonc +5 -0
  7. data/assets/keymaps/vscode.jsonc +5 -0
  8. data/docs/debugging.md +143 -0
  9. data/docs/distribution.md +40 -9
  10. data/docs/lsp.md +131 -0
  11. data/docs/performance.md +18 -0
  12. data/docs/providers.md +36 -0
  13. data/docs/release-log.md +12 -0
  14. data/docs/tasks.md +64 -0
  15. data/docs/testing.md +49 -0
  16. data/docs/vim.md +3 -0
  17. data/exe/canopus +4 -1
  18. data/lib/canopus/buffer.rb +291 -50
  19. data/lib/canopus/cli.rb +5 -2
  20. data/lib/canopus/command.rb +13 -4
  21. data/lib/canopus/controller.rb +183 -40
  22. data/lib/canopus/debug/breakpoints.rb +550 -0
  23. data/lib/canopus/debug/configuration.rb +291 -0
  24. data/lib/canopus/debug/console.rb +243 -0
  25. data/lib/canopus/debug/panel.rb +487 -0
  26. data/lib/canopus/debug/session.rb +354 -0
  27. data/lib/canopus/decoration.rb +4 -2
  28. data/lib/canopus/denebola_compat.rb +27 -0
  29. data/lib/canopus/diagnostics.rb +173 -0
  30. data/lib/canopus/display_map/overlay_map.rb +6 -2
  31. data/lib/canopus/editor/snippet_expandable.rb +3 -2
  32. data/lib/canopus/editor.rb +122 -31
  33. data/lib/canopus/git/state.rb +100 -0
  34. data/lib/canopus/language/background_analysis.rb +93 -14
  35. data/lib/canopus/language/document.rb +70 -34
  36. data/lib/canopus/language/symbol.rb +1 -0
  37. data/lib/canopus/language/syntax_worker.rb +61 -9
  38. data/lib/canopus/language.rb +3 -0
  39. data/lib/canopus/minimap.rb +201 -0
  40. data/lib/canopus/patch.rb +3 -1
  41. data/lib/canopus/plugins/api.rb +4 -2
  42. data/lib/canopus/plugins/isolated_runtime.rb +64 -7
  43. data/lib/canopus/plugins/registry.rb +114 -3
  44. data/lib/canopus/plugins.rb +8 -7
  45. data/lib/canopus/project.rb +15 -6
  46. data/lib/canopus/provider.rb +192 -0
  47. data/lib/canopus/regexp_compat.rb +36 -4
  48. data/lib/canopus/selection.rb +12 -0
  49. data/lib/canopus/settings/schema.rb +201 -0
  50. data/lib/canopus/settings.rb +328 -49
  51. data/lib/canopus/task/configuration.rb +356 -0
  52. data/lib/canopus/task/problem_matcher.rb +264 -0
  53. data/lib/canopus/task/runner.rb +314 -0
  54. data/lib/canopus/test_runner/bounded_ignore.rb +144 -0
  55. data/lib/canopus/test_runner/discovery.rb +97 -0
  56. data/lib/canopus/test_runner/execution.rb +194 -0
  57. data/lib/canopus/test_runner/minitest.rb +116 -0
  58. data/lib/canopus/test_runner/rspec.rb +103 -0
  59. data/lib/canopus/test_runner/safe_walker.rb +37 -0
  60. data/lib/canopus/test_runner.rb +54 -0
  61. data/lib/canopus/theme/import.rb +131 -0
  62. data/lib/canopus/theme.rb +28 -4
  63. data/lib/canopus/version.rb +1 -1
  64. data/lib/canopus/vim/motionable.rb +1 -1
  65. data/lib/canopus/vim.rb +10 -16
  66. data/lib/canopus/workspace/auto_savable.rb +83 -0
  67. data/lib/canopus/workspace/debug_aware.rb +600 -0
  68. data/lib/canopus/workspace/edit/plan.rb +2 -2
  69. data/lib/canopus/workspace/editor_configurable.rb +113 -0
  70. data/lib/canopus/workspace/file_change_aware.rb +11 -3
  71. data/lib/canopus/workspace/git_aware.rb +80 -34
  72. data/lib/canopus/workspace/git_blame.rb +175 -0
  73. data/lib/canopus/workspace/git_conflict_resolution.rb +410 -0
  74. data/lib/canopus/workspace/git_diff_view.rb +641 -0
  75. data/lib/canopus/workspace/git_history.rb +171 -0
  76. data/lib/canopus/workspace/git_remote.rb +267 -0
  77. data/lib/canopus/workspace/git_staging.rb +583 -0
  78. data/lib/canopus/workspace/hierarchy_aware.rb +477 -0
  79. data/lib/canopus/workspace/keymap_aware.rb +66 -0
  80. data/lib/canopus/workspace/language_aware.rb +3721 -135
  81. data/lib/canopus/workspace/language_server_configurable.rb +287 -71
  82. data/lib/canopus/workspace/persistent_undo.rb +169 -0
  83. data/lib/canopus/workspace/problems_aware.rb +131 -0
  84. data/lib/canopus/workspace/project_searchable.rb +88 -11
  85. data/lib/canopus/workspace/project_tree_editable.rb +3 -1
  86. data/lib/canopus/workspace/recoverable.rb +204 -0
  87. data/lib/canopus/workspace/session_persistable.rb +85 -13
  88. data/lib/canopus/workspace/settings_aware.rb +76 -2
  89. data/lib/canopus/workspace/task_aware.rb +194 -0
  90. data/lib/canopus/workspace/test_aware.rb +394 -0
  91. data/lib/canopus/workspace/trust.rb +73 -0
  92. data/lib/canopus/workspace/view/terminal_presentable.rb +298 -47
  93. data/lib/canopus/workspace/view.rb +399 -74
  94. data/lib/canopus/workspace.rb +688 -44
  95. data/lib/canopus.rb +20 -2
  96. data/sig/canopus.rbs +138 -31
  97. data/sig/controller.rbs +10 -3
  98. data/sig/debug.rbs +121 -0
  99. data/sig/decoration.rbs +12 -2
  100. data/sig/diagnostics.rbs +26 -0
  101. data/sig/minimap.rbs +20 -0
  102. data/sig/provider.rbs +29 -0
  103. data/sig/services.rbs +1 -16
  104. data/sig/task.rbs +92 -0
  105. data/sig/test_runner.rbs +109 -0
  106. data/sig/trust.rbs +17 -0
  107. data/sig/workspace_edits.rbs +2 -2
  108. data/sig/workspace_services.rbs +234 -2
  109. data/tools/check_dependencies.rb +21 -2
  110. data/tools/package.rb +172 -14
  111. data/tools/package_signature_test.rb +33 -0
  112. data/tools/package_test.rb +29 -0
  113. data/tools/sign_package.rb +123 -0
  114. data/tools/update.rb +189 -0
  115. data/tools/update_test.rb +71 -0
  116. metadata +206 -31
  117. data/lib/canopus/lsp/client.rb +0 -307
  118. data/lib/canopus/lsp/error.rb +0 -7
  119. data/lib/canopus/lsp/future/subscription.rb +0 -9
  120. data/lib/canopus/lsp/future.rb +0 -87
  121. data/lib/canopus/lsp/protocol.rb +0 -83
  122. data/lib/canopus/lsp/server_error.rb +0 -13
  123. data/lib/canopus/lsp/timeout.rb +0 -7
  124. data/lib/canopus/lsp/transport.rb +0 -123
  125. data/lib/canopus/lsp.rb +0 -19
  126. data/lib/canopus/project/search.rb +0 -164
  127. data/lib/canopus/project/search_worker/cancelled.rb +0 -3
  128. data/lib/canopus/project/search_worker/runner.rb +0 -9
  129. data/lib/canopus/project/search_worker.rb +0 -108
  130. data/lib/canopus/terminal/cell.rb +0 -7
  131. data/lib/canopus/terminal/grid.rb +0 -321
  132. data/lib/canopus/terminal/pty.rb +0 -178
  133. data/lib/canopus/terminal/scrollback.rb +0 -38
  134. data/lib/canopus/terminal/vt.rb +0 -398
  135. data/lib/canopus/terminal.rb +0 -13
  136. data/sig/lsp.rbs +0 -99
  137. data/sig/terminal.rbs +0 -130
@@ -3,5 +3,6 @@
3
3
  module Canopus
4
4
  module Language
5
5
  Symbol = Data.define(:name, :kind, :range, :selection, :depth)
6
+ DocumentSymbol = Data.define(:id, :name, :kind, :range, :selection, :depth, :parent_id)
6
7
  end
7
8
  end
@@ -1,6 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "antares"
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
- syntax = analyze(document, request.fetch("base"), request.fetch("complete")) if request.fetch("syntax")
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.analyze(document, base, complete)
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.__send__(:bracket_ranges).values.uniq
85
- brackets = all_brackets.first(MAX_BRACKETS).map { |range| [base + range.begin, base + range.end] }
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, "folds" => folds,
88
- "truncated" => all_symbols.length > MAX_SYMBOLS || all_brackets.length > MAX_BRACKETS || (complete && document.diagnostics.length > 1000)}
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
@@ -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 { |range, _| range.begin }.map do |range, text|
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
@@ -9,7 +9,9 @@ module Canopus
9
9
  @workspace, @permissions = workspace, permissions
10
10
  end
11
11
  def permit!(permission)
12
- raise Canopus::Plugins::PermissionDenied, "plugin requires #{permission}" unless @permissions.include?(permission)
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!("process")
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
- # It is NOT an OS sandbox: Ruby plugins remain trusted executable code.
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
- raise "plugin requires #{name}" unless @permissions.include?(name)
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!("process")
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
- @input, @output, @process = Open3.popen2(RbConfig.ruby, "-e", WORKER)
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", @process.pid) rescue Errno::ESRCH
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
- PERMISSIONS = %w[read_buffer edit_buffer read_project process network].freeze
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(&:to_s)
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
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Canopus
4
- module Plugins; end
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"