canopus 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +148 -0
  5. data/docs/adr/001-runtime-gem-components.md +24 -0
  6. data/docs/adr/002-bounded-diff-engine.md +22 -0
  7. data/docs/adr/003-background-language-analysis.md +22 -0
  8. data/docs/adr/004-persistent-display-map.md +23 -0
  9. data/docs/adr/005-windows-runtime.md +27 -0
  10. data/docs/adr/README.md +36 -0
  11. data/docs/distribution.md +27 -0
  12. data/docs/lsp.md +38 -0
  13. data/docs/performance.md +40 -0
  14. data/docs/snippets.md +32 -0
  15. data/docs/vim.md +24 -0
  16. data/docs/workspace_edits.md +32 -0
  17. data/examples/native_smoke.rb +25 -0
  18. data/examples/plugins/word_count.rb +11 -0
  19. data/exe/canopus +19 -0
  20. data/lib/canopus/block_map.rb +19 -0
  21. data/lib/canopus/buffer.rb +293 -0
  22. data/lib/canopus/cli.rb +171 -0
  23. data/lib/canopus/controller.rb +507 -0
  24. data/lib/canopus/data_compat.rb +25 -0
  25. data/lib/canopus/display_map/line_builder.rb +54 -0
  26. data/lib/canopus/display_map/line_set.rb +8 -0
  27. data/lib/canopus/display_map/pending_line_set.rb +9 -0
  28. data/lib/canopus/display_map/summary.rb +23 -0
  29. data/lib/canopus/display_map/worker.rb +89 -0
  30. data/lib/canopus/display_map.rb +350 -0
  31. data/lib/canopus/display_point.rb +5 -0
  32. data/lib/canopus/editor/snippet_expandable.rb +281 -0
  33. data/lib/canopus/editor.rb +445 -0
  34. data/lib/canopus/error.rb +5 -0
  35. data/lib/canopus/fold_map.rb +87 -0
  36. data/lib/canopus/git/blame.rb +50 -0
  37. data/lib/canopus/git/commit.rb +7 -0
  38. data/lib/canopus/git/corrupt_object.rb +7 -0
  39. data/lib/canopus/git/diff.rb +131 -0
  40. data/lib/canopus/git/index.rb +94 -0
  41. data/lib/canopus/git/object_database.rb +45 -0
  42. data/lib/canopus/git/pack.rb +186 -0
  43. data/lib/canopus/git/repository.rb +313 -0
  44. data/lib/canopus/git/status.rb +74 -0
  45. data/lib/canopus/git/tree_entry.rb +7 -0
  46. data/lib/canopus/git.rb +15 -0
  47. data/lib/canopus/icon_theme.rb +43 -0
  48. data/lib/canopus/language/background_analysis/job.rb +12 -0
  49. data/lib/canopus/language/background_analysis/scheduler.rb +70 -0
  50. data/lib/canopus/language/background_analysis.rb +280 -0
  51. data/lib/canopus/language/definition.rb +7 -0
  52. data/lib/canopus/language/document.rb +143 -0
  53. data/lib/canopus/language/symbol.rb +7 -0
  54. data/lib/canopus/language/syntax_worker.rb +91 -0
  55. data/lib/canopus/language.rb +42 -0
  56. data/lib/canopus/lazy_rope.rb +218 -0
  57. data/lib/canopus/lsp/client.rb +299 -0
  58. data/lib/canopus/lsp/error.rb +7 -0
  59. data/lib/canopus/lsp/future/subscription.rb +9 -0
  60. data/lib/canopus/lsp/future.rb +87 -0
  61. data/lib/canopus/lsp/protocol.rb +83 -0
  62. data/lib/canopus/lsp/server_error.rb +13 -0
  63. data/lib/canopus/lsp/timeout.rb +7 -0
  64. data/lib/canopus/lsp/transport.rb +123 -0
  65. data/lib/canopus/lsp.rb +19 -0
  66. data/lib/canopus/markdown.rb +69 -0
  67. data/lib/canopus/match_data_compat.rb +17 -0
  68. data/lib/canopus/multi_buffer.rb +266 -0
  69. data/lib/canopus/pane.rb +61 -0
  70. data/lib/canopus/patch/composite.rb +12 -0
  71. data/lib/canopus/patch/reload.rb +18 -0
  72. data/lib/canopus/patch.rb +27 -0
  73. data/lib/canopus/performance_recorder.rb +207 -0
  74. data/lib/canopus/plugins/api.rb +50 -0
  75. data/lib/canopus/plugins/isolated_runtime.rb +167 -0
  76. data/lib/canopus/plugins/local_runtime.rb +25 -0
  77. data/lib/canopus/plugins/permission_denied.rb +7 -0
  78. data/lib/canopus/plugins/registry.rb +30 -0
  79. data/lib/canopus/plugins.rb +11 -0
  80. data/lib/canopus/project/ignore_matcher.rb +104 -0
  81. data/lib/canopus/project/search.rb +164 -0
  82. data/lib/canopus/project/search_worker/cancelled.rb +3 -0
  83. data/lib/canopus/project/search_worker/runner.rb +9 -0
  84. data/lib/canopus/project/search_worker.rb +108 -0
  85. data/lib/canopus/project/tree.rb +48 -0
  86. data/lib/canopus/project/watcher.rb +59 -0
  87. data/lib/canopus/project.rb +127 -0
  88. data/lib/canopus/regexp_compat.rb +30 -0
  89. data/lib/canopus/save_conflict.rb +5 -0
  90. data/lib/canopus/selection.rb +11 -0
  91. data/lib/canopus/settings.rb +118 -0
  92. data/lib/canopus/snippet/transform.rb +209 -0
  93. data/lib/canopus/snippet.rb +183 -0
  94. data/lib/canopus/tab_map.rb +30 -0
  95. data/lib/canopus/terminal/cell.rb +7 -0
  96. data/lib/canopus/terminal/grid.rb +321 -0
  97. data/lib/canopus/terminal/pty.rb +103 -0
  98. data/lib/canopus/terminal/scrollback.rb +38 -0
  99. data/lib/canopus/terminal/vt.rb +398 -0
  100. data/lib/canopus/terminal.rb +13 -0
  101. data/lib/canopus/theme.rb +43 -0
  102. data/lib/canopus/version.rb +5 -0
  103. data/lib/canopus/vim/commandable.rb +148 -0
  104. data/lib/canopus/vim/motionable.rb +321 -0
  105. data/lib/canopus/vim/operator_capable.rb +377 -0
  106. data/lib/canopus/vim/text_object_selectable.rb +141 -0
  107. data/lib/canopus/vim.rb +426 -0
  108. data/lib/canopus/workspace/edit/executable.rb +139 -0
  109. data/lib/canopus/workspace/edit/node.rb +8 -0
  110. data/lib/canopus/workspace/edit/plan.rb +170 -0
  111. data/lib/canopus/workspace/edit/resource_preparable.rb +98 -0
  112. data/lib/canopus/workspace/edit.rb +6 -0
  113. data/lib/canopus/workspace/file_change_aware.rb +40 -0
  114. data/lib/canopus/workspace/file_previewable.rb +31 -0
  115. data/lib/canopus/workspace/git_aware.rb +140 -0
  116. data/lib/canopus/workspace/language_aware.rb +413 -0
  117. data/lib/canopus/workspace/language_server_configurable.rb +181 -0
  118. data/lib/canopus/workspace/project_searchable.rb +208 -0
  119. data/lib/canopus/workspace/project_tree_editable.rb +82 -0
  120. data/lib/canopus/workspace/session_persistable.rb +153 -0
  121. data/lib/canopus/workspace/settings_aware.rb +91 -0
  122. data/lib/canopus/workspace/view/terminal_presentable.rb +185 -0
  123. data/lib/canopus/workspace/view.rb +643 -0
  124. data/lib/canopus/workspace.rb +525 -0
  125. data/lib/canopus/wrap_map.rb +108 -0
  126. data/lib/canopus.rb +24 -0
  127. data/sig/canopus.rbs +375 -0
  128. data/sig/controller.rbs +55 -0
  129. data/sig/display_stages.rbs +49 -0
  130. data/sig/git.rbs +140 -0
  131. data/sig/lsp.rbs +99 -0
  132. data/sig/markdown.rbs +11 -0
  133. data/sig/performance_recorder.rbs +23 -0
  134. data/sig/search_services.rbs +8 -0
  135. data/sig/services.rbs +81 -0
  136. data/sig/snippet.rbs +42 -0
  137. data/sig/terminal.rbs +125 -0
  138. data/sig/vim.rbs +27 -0
  139. data/sig/workspace_edits.rbs +11 -0
  140. data/sig/workspace_services.rbs +117 -0
  141. data/tools/certify_snippet_regex.rb +35 -0
  142. data/tools/check_dependencies.rb +80 -0
  143. data/tools/native_check.rb +70 -0
  144. data/tools/package.rb +113 -0
  145. data/tools/package_test.rb +32 -0
  146. metadata +314 -0
@@ -0,0 +1,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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module LSP
5
+ class ServerError < Error
6
+ attr_reader :code, :data
7
+ def initialize(error)
8
+ @code, @data = error["code"], error["data"]
9
+ super(error["message"])
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module LSP
5
+ class Timeout < Error; end
6
+ end
7
+ 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
@@ -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
@@ -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