canopus 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +148 -0
- data/docs/adr/001-runtime-gem-components.md +24 -0
- data/docs/adr/002-bounded-diff-engine.md +22 -0
- data/docs/adr/003-background-language-analysis.md +22 -0
- data/docs/adr/004-persistent-display-map.md +23 -0
- data/docs/adr/005-windows-runtime.md +27 -0
- data/docs/adr/README.md +36 -0
- data/docs/distribution.md +27 -0
- data/docs/lsp.md +38 -0
- data/docs/performance.md +40 -0
- data/docs/snippets.md +32 -0
- data/docs/vim.md +24 -0
- data/docs/workspace_edits.md +32 -0
- data/examples/native_smoke.rb +25 -0
- data/examples/plugins/word_count.rb +11 -0
- data/exe/canopus +19 -0
- data/lib/canopus/block_map.rb +19 -0
- data/lib/canopus/buffer.rb +293 -0
- data/lib/canopus/cli.rb +171 -0
- data/lib/canopus/controller.rb +507 -0
- data/lib/canopus/data_compat.rb +25 -0
- data/lib/canopus/display_map/line_builder.rb +54 -0
- data/lib/canopus/display_map/line_set.rb +8 -0
- data/lib/canopus/display_map/pending_line_set.rb +9 -0
- data/lib/canopus/display_map/summary.rb +23 -0
- data/lib/canopus/display_map/worker.rb +89 -0
- data/lib/canopus/display_map.rb +350 -0
- data/lib/canopus/display_point.rb +5 -0
- data/lib/canopus/editor/snippet_expandable.rb +281 -0
- data/lib/canopus/editor.rb +445 -0
- data/lib/canopus/error.rb +5 -0
- data/lib/canopus/fold_map.rb +87 -0
- data/lib/canopus/git/blame.rb +50 -0
- data/lib/canopus/git/commit.rb +7 -0
- data/lib/canopus/git/corrupt_object.rb +7 -0
- data/lib/canopus/git/diff.rb +131 -0
- data/lib/canopus/git/index.rb +94 -0
- data/lib/canopus/git/object_database.rb +45 -0
- data/lib/canopus/git/pack.rb +186 -0
- data/lib/canopus/git/repository.rb +313 -0
- data/lib/canopus/git/status.rb +74 -0
- data/lib/canopus/git/tree_entry.rb +7 -0
- data/lib/canopus/git.rb +15 -0
- data/lib/canopus/icon_theme.rb +43 -0
- data/lib/canopus/language/background_analysis/job.rb +12 -0
- data/lib/canopus/language/background_analysis/scheduler.rb +70 -0
- data/lib/canopus/language/background_analysis.rb +280 -0
- data/lib/canopus/language/definition.rb +7 -0
- data/lib/canopus/language/document.rb +143 -0
- data/lib/canopus/language/symbol.rb +7 -0
- data/lib/canopus/language/syntax_worker.rb +91 -0
- data/lib/canopus/language.rb +42 -0
- data/lib/canopus/lazy_rope.rb +218 -0
- data/lib/canopus/lsp/client.rb +299 -0
- data/lib/canopus/lsp/error.rb +7 -0
- data/lib/canopus/lsp/future/subscription.rb +9 -0
- data/lib/canopus/lsp/future.rb +87 -0
- data/lib/canopus/lsp/protocol.rb +83 -0
- data/lib/canopus/lsp/server_error.rb +13 -0
- data/lib/canopus/lsp/timeout.rb +7 -0
- data/lib/canopus/lsp/transport.rb +123 -0
- data/lib/canopus/lsp.rb +19 -0
- data/lib/canopus/markdown.rb +69 -0
- data/lib/canopus/match_data_compat.rb +17 -0
- data/lib/canopus/multi_buffer.rb +266 -0
- data/lib/canopus/pane.rb +61 -0
- data/lib/canopus/patch/composite.rb +12 -0
- data/lib/canopus/patch/reload.rb +18 -0
- data/lib/canopus/patch.rb +27 -0
- data/lib/canopus/performance_recorder.rb +207 -0
- data/lib/canopus/plugins/api.rb +50 -0
- data/lib/canopus/plugins/isolated_runtime.rb +167 -0
- data/lib/canopus/plugins/local_runtime.rb +25 -0
- data/lib/canopus/plugins/permission_denied.rb +7 -0
- data/lib/canopus/plugins/registry.rb +30 -0
- data/lib/canopus/plugins.rb +11 -0
- data/lib/canopus/project/ignore_matcher.rb +104 -0
- data/lib/canopus/project/search.rb +164 -0
- data/lib/canopus/project/search_worker/cancelled.rb +3 -0
- data/lib/canopus/project/search_worker/runner.rb +9 -0
- data/lib/canopus/project/search_worker.rb +108 -0
- data/lib/canopus/project/tree.rb +48 -0
- data/lib/canopus/project/watcher.rb +59 -0
- data/lib/canopus/project.rb +127 -0
- data/lib/canopus/regexp_compat.rb +30 -0
- data/lib/canopus/save_conflict.rb +5 -0
- data/lib/canopus/selection.rb +11 -0
- data/lib/canopus/settings.rb +118 -0
- data/lib/canopus/snippet/transform.rb +209 -0
- data/lib/canopus/snippet.rb +183 -0
- data/lib/canopus/tab_map.rb +30 -0
- data/lib/canopus/terminal/cell.rb +7 -0
- data/lib/canopus/terminal/grid.rb +321 -0
- data/lib/canopus/terminal/pty.rb +103 -0
- data/lib/canopus/terminal/scrollback.rb +38 -0
- data/lib/canopus/terminal/vt.rb +398 -0
- data/lib/canopus/terminal.rb +13 -0
- data/lib/canopus/theme.rb +43 -0
- data/lib/canopus/version.rb +5 -0
- data/lib/canopus/vim/commandable.rb +148 -0
- data/lib/canopus/vim/motionable.rb +321 -0
- data/lib/canopus/vim/operator_capable.rb +377 -0
- data/lib/canopus/vim/text_object_selectable.rb +141 -0
- data/lib/canopus/vim.rb +426 -0
- data/lib/canopus/workspace/edit/executable.rb +139 -0
- data/lib/canopus/workspace/edit/node.rb +8 -0
- data/lib/canopus/workspace/edit/plan.rb +170 -0
- data/lib/canopus/workspace/edit/resource_preparable.rb +98 -0
- data/lib/canopus/workspace/edit.rb +6 -0
- data/lib/canopus/workspace/file_change_aware.rb +40 -0
- data/lib/canopus/workspace/file_previewable.rb +31 -0
- data/lib/canopus/workspace/git_aware.rb +140 -0
- data/lib/canopus/workspace/language_aware.rb +413 -0
- data/lib/canopus/workspace/language_server_configurable.rb +181 -0
- data/lib/canopus/workspace/project_searchable.rb +208 -0
- data/lib/canopus/workspace/project_tree_editable.rb +82 -0
- data/lib/canopus/workspace/session_persistable.rb +153 -0
- data/lib/canopus/workspace/settings_aware.rb +91 -0
- data/lib/canopus/workspace/view/terminal_presentable.rb +185 -0
- data/lib/canopus/workspace/view.rb +643 -0
- data/lib/canopus/workspace.rb +525 -0
- data/lib/canopus/wrap_map.rb +108 -0
- data/lib/canopus.rb +24 -0
- data/sig/canopus.rbs +375 -0
- data/sig/controller.rbs +55 -0
- data/sig/display_stages.rbs +49 -0
- data/sig/git.rbs +140 -0
- data/sig/lsp.rbs +99 -0
- data/sig/markdown.rbs +11 -0
- data/sig/performance_recorder.rbs +23 -0
- data/sig/search_services.rbs +8 -0
- data/sig/services.rbs +81 -0
- data/sig/snippet.rbs +42 -0
- data/sig/terminal.rbs +125 -0
- data/sig/vim.rbs +27 -0
- data/sig/workspace_edits.rbs +11 -0
- data/sig/workspace_services.rbs +117 -0
- data/tools/certify_snippet_regex.rb +35 -0
- data/tools/check_dependencies.rb +80 -0
- data/tools/native_check.rb +70 -0
- data/tools/package.rb +113 -0
- data/tools/package_test.rb +32 -0
- metadata +314 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module LSP
|
|
5
|
+
module Protocol
|
|
6
|
+
module_function
|
|
7
|
+
def uri(path)
|
|
8
|
+
absolute = File.expand_path(path).tr("\\", "/")
|
|
9
|
+
absolute = "/#{absolute}" if absolute.match?(/\A[A-Za-z]:/)
|
|
10
|
+
"file://" + URI::RFC2396_PARSER.escape(absolute, /[^a-zA-Z0-9\-._~\/:]/)
|
|
11
|
+
end
|
|
12
|
+
def path(uri)
|
|
13
|
+
parsed = URI.parse(uri)
|
|
14
|
+
raise Error, "expected local file URI" unless parsed.scheme == "file" && [nil, "", "localhost"].include?(parsed.host) && parsed.query.nil? && parsed.fragment.nil? && parsed.path&.start_with?("/")
|
|
15
|
+
path = URI::RFC2396_PARSER.unescape(parsed.path)
|
|
16
|
+
raise Error, "invalid file URI path" if path.include?("\0") || !path.valid_encoding?
|
|
17
|
+
RUBY_PLATFORM.match?(/mswin|mingw/) ? path.sub(%r{\A/([A-Za-z]:/)}, '\\1') : path
|
|
18
|
+
rescue URI::InvalidURIError => error
|
|
19
|
+
raise Error, error.message
|
|
20
|
+
end
|
|
21
|
+
def position(rope, offset)
|
|
22
|
+
point = rope.utf16_point_at(offset)
|
|
23
|
+
{line: point.row, character: point.column}
|
|
24
|
+
end
|
|
25
|
+
def offset(rope, position)
|
|
26
|
+
raise Error, "invalid LSP position" unless position.is_a?(Hash) && uint?(position["line"]) && uint?(position["character"])
|
|
27
|
+
row, column = position.fetch("line"), position.fetch("character")
|
|
28
|
+
start = rope.line_start(row)
|
|
29
|
+
finish = start + rope.line(row).bytesize
|
|
30
|
+
units = rope.utf16_offset_at(finish) - rope.utf16_offset_at(start)
|
|
31
|
+
rope.offset_at_utf16(rope.utf16_offset_at(start) + [column, units].min)
|
|
32
|
+
end
|
|
33
|
+
def range(rope, range)
|
|
34
|
+
{start: position(rope, range.begin), end: position(rope, range.end + (range.exclude_end? ? 0 : 1))}
|
|
35
|
+
end
|
|
36
|
+
def semantic_delta(data, edits)
|
|
37
|
+
raise Error, "invalid semantic token delta" unless data.is_a?(Array) && edits.is_a?(Array)
|
|
38
|
+
output = data.dup
|
|
39
|
+
last = 0
|
|
40
|
+
edits.each do |edit|
|
|
41
|
+
raise Error, "invalid semantic token delta" unless edit.is_a?(Hash) && uint?(edit["start"]) && uint?(edit["deleteCount"]) && edit.fetch("data", []).is_a?(Array)
|
|
42
|
+
end
|
|
43
|
+
sorted = edits.sort_by { |edit| edit.fetch("start") }
|
|
44
|
+
sorted.each do |edit|
|
|
45
|
+
start, count = edit.fetch("start"), edit.fetch("deleteCount")
|
|
46
|
+
raise Error, "invalid semantic token delta" unless start.is_a?(Integer) && count.is_a?(Integer) && start >= last && count >= 0 && start + count <= data.length
|
|
47
|
+
last = start + count
|
|
48
|
+
end
|
|
49
|
+
sorted.reverse_each { |edit| output[edit.fetch("start"), edit.fetch("deleteCount")] = edit.fetch("data", []) }
|
|
50
|
+
semantic_tokens(output)
|
|
51
|
+
output
|
|
52
|
+
end
|
|
53
|
+
def uint?(value) = value.is_a?(Integer) && value.between?(0, 0x7fffffff)
|
|
54
|
+
def diagnostics(values)
|
|
55
|
+
valid = values.is_a?(Array) && values.all? do |value|
|
|
56
|
+
next false unless value.is_a?(Hash) && value["message"].is_a?(String) && value["range"].is_a?(Hash)
|
|
57
|
+
range = value["range"]
|
|
58
|
+
points = %w[start end].map { |key| range[key] }
|
|
59
|
+
points.all? { |point| point.is_a?(Hash) && uint?(point["line"]) && uint?(point["character"]) } &&
|
|
60
|
+
([points[0]["line"], points[0]["character"]] <=> [points[1]["line"], points[1]["character"]]) <= 0 &&
|
|
61
|
+
(!value.key?("severity") || (value["severity"].is_a?(Integer) && value["severity"].between?(1, 4)))
|
|
62
|
+
end
|
|
63
|
+
raise Error, "invalid LSP diagnostics" unless valid
|
|
64
|
+
values
|
|
65
|
+
end
|
|
66
|
+
def semantic_tokens(data, legend: nil)
|
|
67
|
+
raise Error, "invalid semantic token tuple count" unless data.is_a?(Array) && data.length % 5 == 0
|
|
68
|
+
if legend
|
|
69
|
+
raise Error, "invalid semantic token legend" unless legend.is_a?(Hash) && %w[tokenTypes tokenModifiers].all? { |key| legend[key].is_a?(Array) && legend[key].all? { |name| name.is_a?(String) } }
|
|
70
|
+
end
|
|
71
|
+
row, column = 0, 0
|
|
72
|
+
data.each_slice(5).map do |delta_row, delta_column, length, type, modifiers|
|
|
73
|
+
raise Error, "invalid semantic token value" unless [delta_row, delta_column, length, type, modifiers].all? { |v| uint?(v) } && length.positive?
|
|
74
|
+
raise Error, "semantic token exceeds legend" if legend && (type >= legend["tokenTypes"].length || modifiers.bit_length > legend["tokenModifiers"].length)
|
|
75
|
+
row += delta_row
|
|
76
|
+
column = delta_row.zero? ? column + delta_column : delta_column
|
|
77
|
+
raise Error, "semantic token position overflow" unless uint?(row) && uint?(column + length)
|
|
78
|
+
{line: row, character: column, length: length, type: type, modifiers: modifiers}
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module LSP
|
|
5
|
+
class Transport
|
|
6
|
+
MAX_MESSAGE = 32 << 20
|
|
7
|
+
attr_reader :stderr_lines, :pid
|
|
8
|
+
def initialize(command, cwd: nil, env: {}, &receive)
|
|
9
|
+
raise ArgumentError, "command must be a nonempty argument array" unless command.is_a?(Array) && !command.empty? && command.all? { |part| part.is_a?(String) && !part.include?("\0") }
|
|
10
|
+
raise ArgumentError, "receiver required" unless receive
|
|
11
|
+
options = cwd ? {chdir: cwd} : {}
|
|
12
|
+
@stdin, @stdout, @stderr, @process = Open3.popen3(env, *command, **options)
|
|
13
|
+
@stdin.binmode
|
|
14
|
+
@stdout.binmode
|
|
15
|
+
@pid, @write_lock, @stderr_lines = @process.pid, Mutex.new, []
|
|
16
|
+
@reader = Thread.new do
|
|
17
|
+
begin
|
|
18
|
+
loop do
|
|
19
|
+
message = self.class.read_message(@stdout)
|
|
20
|
+
break unless message
|
|
21
|
+
receive.call(message, nil)
|
|
22
|
+
end
|
|
23
|
+
receive.call(nil, Error.new("language server closed stdout")) unless @closing
|
|
24
|
+
rescue StandardError => error
|
|
25
|
+
begin
|
|
26
|
+
receive.call(nil, error) unless @closing
|
|
27
|
+
rescue StandardError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
@logger = Thread.new do
|
|
33
|
+
while (line = @stderr.gets("\n", 8192))
|
|
34
|
+
@stderr_lines << line.scrub.byteslice(0, 8192).scrub("")
|
|
35
|
+
@stderr_lines.shift if @stderr_lines.length > 200
|
|
36
|
+
end
|
|
37
|
+
rescue IOError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
def self.read_message(io)
|
|
42
|
+
headers, count = {}, 0
|
|
43
|
+
loop do
|
|
44
|
+
line = io.gets("\r\n", 8193)
|
|
45
|
+
return nil if line.nil? && headers.empty?
|
|
46
|
+
raise Error, "truncated or oversized LSP header" unless line && line.end_with?("\r\n") && line.bytesize <= 8192
|
|
47
|
+
break if line == "\r\n"
|
|
48
|
+
count += line.bytesize
|
|
49
|
+
raise Error, "oversized LSP headers" if count > 16384
|
|
50
|
+
raise Error, "non-ASCII LSP header" unless line.ascii_only?
|
|
51
|
+
key, value = line.strip.split(":", 2)
|
|
52
|
+
raise Error, "invalid LSP header" unless value && key.match?(/\A[A-Za-z][A-Za-z0-9-]*\z/)
|
|
53
|
+
key = key.downcase
|
|
54
|
+
raise Error, "duplicate LSP header" if headers.key?(key)
|
|
55
|
+
headers[key] = value.strip
|
|
56
|
+
end
|
|
57
|
+
raw_length = headers["content-length"]
|
|
58
|
+
raise Error, "missing or invalid Content-Length" unless raw_length&.match?(/\A\d+\z/)
|
|
59
|
+
length = Integer(raw_length, 10)
|
|
60
|
+
raise Error, "oversized LSP message" unless length.between?(1, MAX_MESSAGE)
|
|
61
|
+
charset = headers["content-type"]&.match(/charset\s*=\s*"?([^;"\s]+)/i)&.[](1)
|
|
62
|
+
raise Error, "unsupported LSP character encoding" if charset && !%w[utf-8 utf8].include?(charset.downcase)
|
|
63
|
+
body = io.read(length)
|
|
64
|
+
raise Error, "truncated LSP body" unless body && body.bytesize == length
|
|
65
|
+
body.force_encoding(Encoding::UTF_8)
|
|
66
|
+
raise Error, "invalid LSP UTF-8 body" unless body.valid_encoding?
|
|
67
|
+
validate_message(JSON.parse(body))
|
|
68
|
+
rescue JSON::ParserError => error
|
|
69
|
+
raise Error, "invalid LSP JSON: #{error.message.byteslice(0, 256)}"
|
|
70
|
+
end
|
|
71
|
+
def self.validate_message(message)
|
|
72
|
+
raise Error, "invalid JSON-RPC message" unless message.is_a?(Hash) && message["jsonrpc"] == "2.0"
|
|
73
|
+
if message.key?("method")
|
|
74
|
+
raise Error, "invalid JSON-RPC method" unless message["method"].is_a?(String) && !message["method"].empty?
|
|
75
|
+
raise Error, "invalid JSON-RPC parameters" if message.key?("params") && !message["params"].is_a?(Hash) && !message["params"].is_a?(Array)
|
|
76
|
+
raise Error, "request contains a response" if message.key?("result") || message.key?("error")
|
|
77
|
+
else
|
|
78
|
+
raise Error, "invalid JSON-RPC response" unless message.key?("id") && (message.key?("result") ^ message.key?("error"))
|
|
79
|
+
if message.key?("error")
|
|
80
|
+
error = message["error"]
|
|
81
|
+
raise Error, "invalid JSON-RPC error" unless error.is_a?(Hash) && error["code"].is_a?(Integer) && error["message"].is_a?(String)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
id = message["id"]
|
|
85
|
+
raise Error, "invalid JSON-RPC id" if message.key?("id") && !id.is_a?(Integer) && !id.is_a?(String) && !(id.nil? && !message.key?("method"))
|
|
86
|
+
message
|
|
87
|
+
end
|
|
88
|
+
def write(message)
|
|
89
|
+
raise Error, "expected JSON-RPC object" unless message.is_a?(Hash)
|
|
90
|
+
normalized = message.transform_keys(&:to_s)
|
|
91
|
+
normalized["error"] = normalized["error"].transform_keys(&:to_s) if normalized["error"].is_a?(Hash)
|
|
92
|
+
self.class.validate_message(normalized)
|
|
93
|
+
body = JSON.generate(message).b
|
|
94
|
+
raise Error, "oversized LSP message" unless body.bytesize.between?(1, MAX_MESSAGE)
|
|
95
|
+
@write_lock.synchronize do
|
|
96
|
+
@stdin.write("Content-Length: #{body.bytesize}\r\n\r\n")
|
|
97
|
+
@stdin.write(body)
|
|
98
|
+
@stdin.flush
|
|
99
|
+
end
|
|
100
|
+
rescue IOError, Errno::EPIPE => error
|
|
101
|
+
raise Error, "language server write failed: #{error.message}"
|
|
102
|
+
end
|
|
103
|
+
def alive? = @process.alive?
|
|
104
|
+
def close
|
|
105
|
+
return if @closing
|
|
106
|
+
@closing = true
|
|
107
|
+
@stdin.close unless @stdin.closed?
|
|
108
|
+
unless @process.join(1)
|
|
109
|
+
Process.kill("TERM", @pid) rescue Errno::ESRCH
|
|
110
|
+
unless @process.join(1)
|
|
111
|
+
Process.kill("KILL", @pid) rescue Errno::ESRCH
|
|
112
|
+
@process.join
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
[@stdout, @stderr].each { |io| io.close unless io.closed? }
|
|
116
|
+
[@reader, @logger].each do |thread|
|
|
117
|
+
next if thread == Thread.current
|
|
118
|
+
thread.kill unless thread.join(1)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
data/lib/canopus/lsp.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "thread"
|
|
7
|
+
|
|
8
|
+
module Canopus
|
|
9
|
+
module LSP; end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
require_relative "lsp/error"
|
|
13
|
+
require_relative "lsp/timeout"
|
|
14
|
+
require_relative "lsp/server_error"
|
|
15
|
+
require_relative "lsp/protocol"
|
|
16
|
+
require_relative "lsp/future"
|
|
17
|
+
require_relative "lsp/future/subscription"
|
|
18
|
+
require_relative "lsp/transport"
|
|
19
|
+
require_relative "lsp/client"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rouge"
|
|
4
|
+
require "unicode/display_width"
|
|
5
|
+
|
|
6
|
+
module Canopus
|
|
7
|
+
# Bounded hover content. Rouge already understands Markdown and fenced code;
|
|
8
|
+
# HTML and images remain text and never fetch resources or execute anything.
|
|
9
|
+
class Markdown
|
|
10
|
+
Run = Data.define(:text, :style, :url)
|
|
11
|
+
attr_reader :rows
|
|
12
|
+
|
|
13
|
+
def initialize(source, width: 72, max_rows: 18, markup: true)
|
|
14
|
+
raise ArgumentError, "invalid hover dimensions" unless width.is_a?(Integer) && width.positive? && max_rows.is_a?(Integer) && max_rows.positive?
|
|
15
|
+
source = source.to_s.byteslice(0, 65_536).to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).scrub
|
|
16
|
+
tokens = markup ? Rouge::Lexer.find("markdown").new.lex(source).map { |token, value| [token.qualname, value] } : [["Text", source]]
|
|
17
|
+
@rows, column, index = [[]], 0, 0
|
|
18
|
+
while index < tokens.length && @rows.length <= max_rows
|
|
19
|
+
kind, value = tokens[index]
|
|
20
|
+
style, url = :text, nil
|
|
21
|
+
if markup && value == "[" && tokens[index + 1]&.first == "Name.Variable" && tokens[index + 2]&.last == "](" && tokens[index + 3]&.first == "Literal.String.Other" && tokens[index + 4]&.last == ")"
|
|
22
|
+
value, url, style = tokens[index + 1].last, tokens[index + 3].last, :link
|
|
23
|
+
index += 4
|
|
24
|
+
elsif markup
|
|
25
|
+
case kind
|
|
26
|
+
when "Generic.Heading", "Generic.Subheading"
|
|
27
|
+
value, style = value.sub(/\A\#{1,6}\s*/, ""), :heading
|
|
28
|
+
when "Generic.Strong" then value, style = value[2...-2], :strong
|
|
29
|
+
when "Generic.Emph" then value, style = value[1...-1], :emphasis
|
|
30
|
+
when "Literal.String.Backtick" then value, style = value.sub(/\A`+/, "").sub(/`+\z/, ""), :code
|
|
31
|
+
when "Generic.Traceback" then value, style = value.sub(/\A>\s?/, "│ "), :quote
|
|
32
|
+
when "Punctuation"
|
|
33
|
+
if value.match?(/\A(?:`{3,}|~{3,})\z/)
|
|
34
|
+
index += 1 if tokens[index + 1]&.first == "Name.Label"
|
|
35
|
+
value = ""
|
|
36
|
+
end
|
|
37
|
+
else
|
|
38
|
+
style = kind unless kind == "Text"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
value.to_s.each_grapheme_cluster do |grapheme|
|
|
42
|
+
if grapheme.include?("\n")
|
|
43
|
+
@rows << []
|
|
44
|
+
column = 0
|
|
45
|
+
break if @rows.length > max_rows
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
cells = Unicode::DisplayWidth.of(grapheme, emoji: :rgi)
|
|
49
|
+
if column.positive? && column + cells > width
|
|
50
|
+
@rows << []
|
|
51
|
+
column = 0
|
|
52
|
+
break if @rows.length > max_rows
|
|
53
|
+
end
|
|
54
|
+
previous = @rows.last.last
|
|
55
|
+
if previous && previous.style == style && previous.url == url
|
|
56
|
+
previous.text << grapheme
|
|
57
|
+
else
|
|
58
|
+
@rows.last << Run.new(+grapheme, style, url)
|
|
59
|
+
end
|
|
60
|
+
column += cells
|
|
61
|
+
end
|
|
62
|
+
index += 1
|
|
63
|
+
end
|
|
64
|
+
@rows = @rows.first(max_rows)
|
|
65
|
+
@rows.pop while @rows.length > 1 && @rows.last.empty?
|
|
66
|
+
@rows.each { |row| row.each { |run| run.text.freeze }; row.freeze }.freeze
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
unless MatchData.method_defined?(:byteoffset) && MatchData.method_defined?(:bytebegin)
|
|
4
|
+
class MatchData
|
|
5
|
+
unless method_defined?(:byteoffset)
|
|
6
|
+
def byteoffset(index)
|
|
7
|
+
first, last = offset(index)
|
|
8
|
+
first && [string[0...first].bytesize, string[0...last].bytesize]
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
unless method_defined?(:bytebegin)
|
|
13
|
+
def bytebegin(index) = byteoffset(index)&.first
|
|
14
|
+
def byteend(index) = byteoffset(index)&.last
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
# Editable, nonoverlapping source excerpts with protected headings.
|
|
5
|
+
class MultiBuffer < Buffer
|
|
6
|
+
Excerpt = Struct.new(:buffer, :first, :last, :title, :view_start, :view_end)
|
|
7
|
+
attr_reader :excerpts
|
|
8
|
+
def initialize(excerpts: [])
|
|
9
|
+
super("")
|
|
10
|
+
@excerpts, @source_subscriptions, @multi_undo, @multi_redo = [], {}, [], []
|
|
11
|
+
validate_excerpt_ranges(excerpts)
|
|
12
|
+
@building = true
|
|
13
|
+
excerpts.each { |buffer, range, title| add_excerpt(buffer, range, title: title) }
|
|
14
|
+
@building = false
|
|
15
|
+
refresh
|
|
16
|
+
end
|
|
17
|
+
def add_excerpt(buffer, range, title: nil)
|
|
18
|
+
raise Error, "excerpt source must be a regular buffer" unless buffer.is_a?(Buffer) && !buffer.is_a?(MultiBuffer)
|
|
19
|
+
ending = range.end + (range.exclude_end? ? 0 : 1)
|
|
20
|
+
buffer.rope.byteslice(range.begin, ending - range.begin)
|
|
21
|
+
overlap = !@building && @excerpts.any? do |excerpt|
|
|
22
|
+
excerpt.buffer.equal?(buffer) && range.begin <= buffer.resolve(excerpt.last) && ending >= buffer.resolve(excerpt.first)
|
|
23
|
+
end
|
|
24
|
+
raise Error, "excerpts of the same buffer must be disjoint and nonadjacent" if overlap
|
|
25
|
+
label = title || "#{buffer.path || 'Untitled'}:#{buffer.rope.point_at(range.begin).row + 1}"
|
|
26
|
+
@excerpts << Excerpt.new(buffer, buffer.anchor(range.begin, bias: :left), buffer.anchor(ending, bias: :right), label.to_s.freeze)
|
|
27
|
+
@source_subscriptions[buffer] ||= buffer.on_edit { refresh unless @syncing }
|
|
28
|
+
refresh unless @building
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Attach a privately prepared projection to live sources on the foreground
|
|
33
|
+
# thread. Source ropes must be the exact immutable snapshots used to build
|
|
34
|
+
# it; caller additionally validates workspace identity/version before this.
|
|
35
|
+
# No text rebuild, regex scan or disk IO occurs here.
|
|
36
|
+
def attach_sources(replacements)
|
|
37
|
+
raise Error, "cannot attach an edited projection" unless @multi_undo.empty? && @multi_redo.empty?
|
|
38
|
+
return self if replacements.empty?
|
|
39
|
+
replacements.each do |source, target|
|
|
40
|
+
raise Error, "unknown excerpt source" unless @source_subscriptions.key?(source)
|
|
41
|
+
raise Error, "excerpt source must be a regular buffer" unless target.is_a?(Buffer) && !target.is_a?(MultiBuffer)
|
|
42
|
+
raise Error, "source buffer is read-only" if target.read_only
|
|
43
|
+
raise Error, "excerpt source snapshot changed" unless source.rope.equal?(target.rope)
|
|
44
|
+
end
|
|
45
|
+
targets = @source_subscriptions.keys.map { |source| replacements.fetch(source, source) }
|
|
46
|
+
raise Error, "excerpt source aliases would overlap" unless targets.uniq.length == targets.length
|
|
47
|
+
anchors = []
|
|
48
|
+
subscriptions = {}
|
|
49
|
+
replacements.each do |source, target|
|
|
50
|
+
subscriptions[target] = target.on_edit { refresh unless @syncing } unless source.equal?(target)
|
|
51
|
+
end
|
|
52
|
+
@excerpts.each do |excerpt|
|
|
53
|
+
target = replacements[excerpt.buffer]
|
|
54
|
+
next unless target && !target.equal?(excerpt.buffer)
|
|
55
|
+
entry = [excerpt, target, target.copy_anchor(excerpt.buffer, excerpt.first)]
|
|
56
|
+
anchors << entry
|
|
57
|
+
entry << target.copy_anchor(excerpt.buffer, excerpt.last)
|
|
58
|
+
end
|
|
59
|
+
committed = true
|
|
60
|
+
anchors.each do |excerpt, target, first, last|
|
|
61
|
+
excerpt.buffer.release_anchor(excerpt.first)
|
|
62
|
+
excerpt.buffer.release_anchor(excerpt.last)
|
|
63
|
+
excerpt.buffer, excerpt.first, excerpt.last = target, first, last
|
|
64
|
+
end
|
|
65
|
+
@source_subscriptions = @source_subscriptions.to_h do |source, subscription|
|
|
66
|
+
target = replacements.fetch(source, source)
|
|
67
|
+
if source.equal?(target)
|
|
68
|
+
[source, subscription]
|
|
69
|
+
else
|
|
70
|
+
subscription.detach
|
|
71
|
+
[target, subscriptions.fetch(target)]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
self
|
|
75
|
+
rescue StandardError
|
|
76
|
+
unless committed
|
|
77
|
+
subscriptions&.each_value(&:detach)
|
|
78
|
+
anchors&.each do |_, target, first, last|
|
|
79
|
+
target.release_anchor(first)
|
|
80
|
+
target.release_anchor(last) if last
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
raise
|
|
84
|
+
end
|
|
85
|
+
def dirty? = @excerpts.any? { |excerpt| excerpt.buffer.dirty? }
|
|
86
|
+
def begin_undo_group
|
|
87
|
+
@multi_undo_start = @multi_undo.length unless @undo_group_depth&.positive?
|
|
88
|
+
@excerpts.map(&:buffer).uniq.each(&:begin_undo_group)
|
|
89
|
+
super
|
|
90
|
+
end
|
|
91
|
+
def end_undo_group
|
|
92
|
+
super
|
|
93
|
+
@excerpts.map(&:buffer).uniq.each(&:end_undo_group)
|
|
94
|
+
end
|
|
95
|
+
def edit(changes, **options)
|
|
96
|
+
return super if @refreshing
|
|
97
|
+
return if changes.empty?
|
|
98
|
+
@rope.apply_edits(changes)
|
|
99
|
+
grouped = changes.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |(range, text), result|
|
|
100
|
+
ending = range.end + (range.exclude_end? ? 0 : 1)
|
|
101
|
+
excerpt = @excerpts.find { |item| range.begin >= item.view_start && ending <= item.view_end }
|
|
102
|
+
raise Error, "search-result headings cannot be edited" unless excerpt
|
|
103
|
+
raise Error, "source buffer is read-only" if excerpt.buffer.read_only
|
|
104
|
+
start = excerpt.buffer.resolve(excerpt.first)
|
|
105
|
+
result[excerpt.buffer] << [(start + range.begin - excerpt.view_start)...(start + ending - excerpt.view_start), text]
|
|
106
|
+
end
|
|
107
|
+
patches = grouped.to_h do |buffer, edits|
|
|
108
|
+
[buffer, Patch.new(buffer.rope, buffer.rope.apply_edits(edits), edits)]
|
|
109
|
+
end
|
|
110
|
+
checkpoints = grouped.keys.to_h { |buffer| [buffer, buffer.send(:edit_checkpoint)] }
|
|
111
|
+
before = grouped.keys.to_h { |buffer| [buffer, buffer.rope] }
|
|
112
|
+
@syncing = true
|
|
113
|
+
applied = []
|
|
114
|
+
begin
|
|
115
|
+
grouped.each do |buffer, edits|
|
|
116
|
+
begin
|
|
117
|
+
buffer.edit(edits, kind: :multi_buffer)
|
|
118
|
+
ensure
|
|
119
|
+
# A source may mutate and then raise. Conversely a pre-commit
|
|
120
|
+
# failure must never undo an unrelated previous source edit.
|
|
121
|
+
applied << buffer unless buffer.rope.equal?(before.fetch(buffer))
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
result = super(changes, **options)
|
|
125
|
+
rebuild_text
|
|
126
|
+
after = grouped.keys.to_h { |buffer| [buffer, buffer.rope] }
|
|
127
|
+
if @undo_group_depth&.positive? && @multi_undo.length > @multi_undo_start
|
|
128
|
+
previous = @multi_undo.last
|
|
129
|
+
previous[0] = before.merge(previous.first)
|
|
130
|
+
previous[1] = previous.last.merge(after)
|
|
131
|
+
else
|
|
132
|
+
@multi_undo << [before, after]
|
|
133
|
+
end
|
|
134
|
+
@multi_redo.clear
|
|
135
|
+
result
|
|
136
|
+
rescue StandardError
|
|
137
|
+
applied.reverse_each do |buffer|
|
|
138
|
+
buffer.send(:restore_edit, checkpoints.fetch(buffer), patches.fetch(buffer).inverse)
|
|
139
|
+
rescue StandardError
|
|
140
|
+
# Preserve the original application error, and still restore the
|
|
141
|
+
# other sources. Notification failures are isolated by Buffer.
|
|
142
|
+
end
|
|
143
|
+
raise
|
|
144
|
+
ensure
|
|
145
|
+
@syncing = false
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
def undo
|
|
149
|
+
raise Error, "end the undo group before undo" if @undo_group_depth&.positive?
|
|
150
|
+
group = @multi_undo.last
|
|
151
|
+
return false unless group
|
|
152
|
+
raise Error, "source changed since this excerpt edit" unless group.last.all? { |buffer, snapshot| buffer.rope.equal?(snapshot) }
|
|
153
|
+
@syncing = true
|
|
154
|
+
group.last.keys.reverse_each(&:undo)
|
|
155
|
+
super
|
|
156
|
+
@multi_redo << @multi_undo.pop
|
|
157
|
+
rebuild_text
|
|
158
|
+
true
|
|
159
|
+
ensure
|
|
160
|
+
@syncing = false
|
|
161
|
+
end
|
|
162
|
+
def redo
|
|
163
|
+
raise Error, "end the undo group before redo" if @undo_group_depth&.positive?
|
|
164
|
+
group = @multi_redo.last
|
|
165
|
+
return false unless group
|
|
166
|
+
raise Error, "source changed since this excerpt edit" unless group.first.all? { |buffer, snapshot| buffer.rope.equal?(snapshot) }
|
|
167
|
+
@syncing = true
|
|
168
|
+
group.first.keys.each(&:redo)
|
|
169
|
+
super
|
|
170
|
+
@multi_undo << @multi_redo.pop
|
|
171
|
+
rebuild_text
|
|
172
|
+
true
|
|
173
|
+
ensure
|
|
174
|
+
@syncing = false
|
|
175
|
+
end
|
|
176
|
+
def save(path = nil, force: false)
|
|
177
|
+
raise Error, "Save excerpt files individually to choose new paths" if path
|
|
178
|
+
@excerpts.map(&:buffer).uniq.each { |buffer| buffer.save(force: force) if buffer.dirty? }
|
|
179
|
+
self
|
|
180
|
+
end
|
|
181
|
+
def close
|
|
182
|
+
@source_subscriptions.each_value(&:detach)
|
|
183
|
+
@excerpts.each do |excerpt|
|
|
184
|
+
excerpt.buffer.release_anchor(excerpt.first)
|
|
185
|
+
excerpt.buffer.release_anchor(excerpt.last)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
# Validate grouped, sorted intervals once. Repeated add_excerpt overlap
|
|
191
|
+
# scans made a 10,000-excerpt constructor quadratic in excerpt count.
|
|
192
|
+
def validate_excerpt_ranges(excerpts)
|
|
193
|
+
excerpts.group_by(&:first).each do |buffer, entries|
|
|
194
|
+
raise Error, "excerpt source must be a regular buffer" unless buffer.is_a?(Buffer) && !buffer.is_a?(MultiBuffer)
|
|
195
|
+
previous_end = nil
|
|
196
|
+
entries.sort_by { |_, range, _| range.begin }.each do |_, range, _|
|
|
197
|
+
ending = range.end + (range.exclude_end? ? 0 : 1)
|
|
198
|
+
buffer.rope.byteslice(range.begin, ending - range.begin)
|
|
199
|
+
raise Error, "excerpts of the same buffer must be disjoint and nonadjacent" if previous_end && range.begin <= previous_end
|
|
200
|
+
previous_end = ending
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def rebuild_text
|
|
206
|
+
text = +""
|
|
207
|
+
@excerpts.each do |excerpt|
|
|
208
|
+
text << "#{excerpt.title}\n"
|
|
209
|
+
excerpt.view_start = text.bytesize
|
|
210
|
+
first, last = excerpt.buffer.resolve(excerpt.first), excerpt.buffer.resolve(excerpt.last)
|
|
211
|
+
text << excerpt.buffer.rope.byteslice(first...last).to_s
|
|
212
|
+
excerpt.view_end = text.bytesize
|
|
213
|
+
text << "\n\n"
|
|
214
|
+
end
|
|
215
|
+
text
|
|
216
|
+
end
|
|
217
|
+
def refresh
|
|
218
|
+
normalize_excerpts
|
|
219
|
+
text = rebuild_text
|
|
220
|
+
@refreshing = true
|
|
221
|
+
edit([[0...@rope.bytesize, text]], kind: :source_refresh)
|
|
222
|
+
@saved_rope = @rope
|
|
223
|
+
@history.clear
|
|
224
|
+
@redo.clear
|
|
225
|
+
@multi_undo.clear
|
|
226
|
+
@multi_redo.clear
|
|
227
|
+
ensure
|
|
228
|
+
@refreshing = false
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# External source edits can collapse previously disjoint anchored ranges.
|
|
232
|
+
# Keep their union once, under the earliest heading, so every displayed byte
|
|
233
|
+
# still has one editable source location. No source text is discarded.
|
|
234
|
+
def normalize_excerpts
|
|
235
|
+
removed = {}.compare_by_identity
|
|
236
|
+
@excerpts.each_with_index.group_by { |excerpt, _| excerpt.buffer }.each do |buffer, values|
|
|
237
|
+
group, first, last = [], nil, nil
|
|
238
|
+
values.sort_by { |excerpt, _| buffer.resolve(excerpt.first) }.each do |entry|
|
|
239
|
+
excerpt = entry.first
|
|
240
|
+
start, ending = buffer.resolve(excerpt.first), buffer.resolve(excerpt.last)
|
|
241
|
+
if last && start > last
|
|
242
|
+
merge_excerpts(buffer, group, first, last, removed)
|
|
243
|
+
group = []
|
|
244
|
+
end
|
|
245
|
+
first = start if group.empty?
|
|
246
|
+
last = group.empty? ? ending : [last, ending].max
|
|
247
|
+
group << entry
|
|
248
|
+
end
|
|
249
|
+
merge_excerpts(buffer, group, first, last, removed)
|
|
250
|
+
end
|
|
251
|
+
@excerpts.reject! { |excerpt| removed.key?(excerpt) }
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def merge_excerpts(buffer, entries, first, last, removed)
|
|
255
|
+
return if entries.length < 2
|
|
256
|
+
keeper = entries.min_by(&:last).first
|
|
257
|
+
entries.each do |excerpt, _|
|
|
258
|
+
buffer.release_anchor(excerpt.first)
|
|
259
|
+
buffer.release_anchor(excerpt.last)
|
|
260
|
+
removed[excerpt] = true unless excerpt.equal?(keeper)
|
|
261
|
+
end
|
|
262
|
+
keeper.first = buffer.anchor(first, bias: :left)
|
|
263
|
+
keeper.last = buffer.anchor(last, bias: :right)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
data/lib/canopus/pane.rb
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
class Pane
|
|
5
|
+
attr_reader :editors, :pinned
|
|
6
|
+
attr_accessor :active_index
|
|
7
|
+
def initialize
|
|
8
|
+
@editors, @pinned, @active_index, @past, @future = [], [], 0, [], []
|
|
9
|
+
end
|
|
10
|
+
def active = @editors[@active_index]
|
|
11
|
+
def open(buffer)
|
|
12
|
+
existing = @editors.index { |editor| editor.buffer.equal?(buffer) }
|
|
13
|
+
if existing
|
|
14
|
+
activate(existing)
|
|
15
|
+
else
|
|
16
|
+
@editors << Editor.new(buffer)
|
|
17
|
+
activate(@editors.length - 1)
|
|
18
|
+
end
|
|
19
|
+
active
|
|
20
|
+
end
|
|
21
|
+
def activate(index)
|
|
22
|
+
raise IndexError, "tab outside pane" unless index.is_a?(Integer) && index.between?(0, @editors.length - 1)
|
|
23
|
+
@past << active if active && @active_index != index
|
|
24
|
+
@active_index = index
|
|
25
|
+
@future.clear
|
|
26
|
+
end
|
|
27
|
+
def pin(editor = active)
|
|
28
|
+
@pinned << editor unless @pinned.include?(editor)
|
|
29
|
+
end
|
|
30
|
+
def close(editor = active, discard: false)
|
|
31
|
+
raise Error, "buffer has unsaved changes" if editor.buffer.dirty? && !discard
|
|
32
|
+
@pinned.delete(editor)
|
|
33
|
+
@editors.delete(editor)
|
|
34
|
+
@past.delete(editor)
|
|
35
|
+
@future.delete(editor)
|
|
36
|
+
@active_index = [@active_index, @editors.length - 1].min.clamp(0, @editors.length)
|
|
37
|
+
editor.dispose
|
|
38
|
+
end
|
|
39
|
+
def detach(editor)
|
|
40
|
+
raise Error, "tab is not in pane" unless @editors.include?(editor)
|
|
41
|
+
active = self.active
|
|
42
|
+
@pinned.delete(editor)
|
|
43
|
+
@editors.delete(editor)
|
|
44
|
+
@past.delete(editor)
|
|
45
|
+
@future.delete(editor)
|
|
46
|
+
@active_index = @editors.index(active) || [@active_index, @editors.length - 1].min.clamp(0, @editors.length)
|
|
47
|
+
end
|
|
48
|
+
def back
|
|
49
|
+
previous = @past.pop
|
|
50
|
+
return unless previous && @editors.include?(previous)
|
|
51
|
+
@future << active
|
|
52
|
+
@active_index = @editors.index(previous)
|
|
53
|
+
end
|
|
54
|
+
def forward
|
|
55
|
+
following = @future.pop
|
|
56
|
+
return unless following && @editors.include?(following)
|
|
57
|
+
@past << active
|
|
58
|
+
@active_index = @editors.index(following)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Canopus::Patch::Composite
|
|
4
|
+
attr_reader :patches
|
|
5
|
+
def initialize(patches) = @patches = patches.freeze
|
|
6
|
+
def before = patches.first.before
|
|
7
|
+
def after = patches.last.after
|
|
8
|
+
def edits = patches.flat_map(&:edits)
|
|
9
|
+
def map_offset(offset, bias: :right) = patches.reduce(offset) { |position, patch| patch.map_offset(position, bias: bias) }
|
|
10
|
+
def inverse = self.class.new(patches.reverse.map(&:inverse))
|
|
11
|
+
def compose(other) = self.class.new([*patches, other])
|
|
12
|
+
end
|