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,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "kochab"
|
|
5
|
+
|
|
6
|
+
module Canopus
|
|
7
|
+
class Settings
|
|
8
|
+
DEFAULTS = {"font_size" => 14, "tab_size" => 4, "use_tabs" => false, "soft_wrap" => false, "vim_mode" => false, "keymap" => [].freeze,
|
|
9
|
+
"scroll_friction" => 12,
|
|
10
|
+
"theme" => "Canopus Dark", "font_family" => nil, "icon_theme" => nil, "languages" => {}, "language_servers" => {}}.freeze
|
|
11
|
+
SCHEMA = {"$schema" => "https://json-schema.org/draft/2020-12/schema", "type" => "object", "properties" => {
|
|
12
|
+
"font_size" => {"type" => "number", "minimum" => 6, "maximum" => 96},
|
|
13
|
+
"tab_size" => {"type" => "integer", "minimum" => 1, "maximum" => 16},
|
|
14
|
+
"scroll_friction" => {"type" => "number", "minimum" => 0, "maximum" => 100},
|
|
15
|
+
"soft_wrap" => {"type" => "boolean"}, "vim_mode" => {"type" => "boolean"},
|
|
16
|
+
"use_tabs" => {"type" => "boolean"},
|
|
17
|
+
"keymap" => {"type" => "array", "maxItems" => 128, "items" => {"type" => "object", "required" => ["bindings"], "properties" => {
|
|
18
|
+
"context" => {"type" => "string", "maxLength" => 256},
|
|
19
|
+
"bindings" => {"type" => "object", "maxProperties" => 1024, "additionalProperties" => {"type" => ["string", "null"]}}}}},
|
|
20
|
+
"theme" => {"type" => "string"}, "font_family" => {"type" => ["string", "null"]},
|
|
21
|
+
"icon_theme" => {"type" => ["string", "null"]},
|
|
22
|
+
"languages" => {"type" => "object", "additionalProperties" => {"$ref" => "#"}},
|
|
23
|
+
"language_servers" => {"type" => "object"}}}.freeze
|
|
24
|
+
attr_reader :values, :errors, :layers
|
|
25
|
+
def self.schema = SCHEMA
|
|
26
|
+
def self.user_path
|
|
27
|
+
File.join(ENV["XDG_CONFIG_HOME"] || File.expand_path("~/.config"), "canopus", "settings.jsonc")
|
|
28
|
+
end
|
|
29
|
+
def initialize(*layers)
|
|
30
|
+
@values, @errors = DEFAULTS.dup, []
|
|
31
|
+
@layers = layers.compact
|
|
32
|
+
layers.compact.each { |layer| merge!(layer.is_a?(String) ? parse_file(layer) : layer) }
|
|
33
|
+
validate!
|
|
34
|
+
end
|
|
35
|
+
def [](key) = @values[key.to_s]
|
|
36
|
+
def paths = @layers.grep(String)
|
|
37
|
+
def reload = self.class.new(*@layers)
|
|
38
|
+
def for_language(language) = Settings.new(@values, @values.fetch("languages", {}).fetch(language, {}))
|
|
39
|
+
def merge!(layer)
|
|
40
|
+
raise Error, "settings must be an object" unless layer.is_a?(Hash)
|
|
41
|
+
previous = @values
|
|
42
|
+
@values = merge(@values, layer)
|
|
43
|
+
validate!
|
|
44
|
+
self
|
|
45
|
+
rescue StandardError
|
|
46
|
+
@values = previous if previous
|
|
47
|
+
raise
|
|
48
|
+
end
|
|
49
|
+
def set_file(path, key, value)
|
|
50
|
+
source = File.file?(path) ? File.read(path) : "{}\n"
|
|
51
|
+
doc = Kochab.parse(source)
|
|
52
|
+
raise Error, "cannot edit invalid settings" unless doc.valid?
|
|
53
|
+
replacement = Kochab.apply(source, doc.set([key.to_s], value))
|
|
54
|
+
Settings.new(Kochab.parse(replacement).value)
|
|
55
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
56
|
+
Tempfile.create([".settings-", ".json"], File.dirname(path)) do |file|
|
|
57
|
+
file.write(replacement)
|
|
58
|
+
file.flush
|
|
59
|
+
file.fsync
|
|
60
|
+
file.close
|
|
61
|
+
raise SaveConflict, "settings changed during save" if File.file?(path) && File.read(path) != source
|
|
62
|
+
File.rename(file.path, path)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
private
|
|
66
|
+
def parse_file(path)
|
|
67
|
+
return {} unless File.file?(path)
|
|
68
|
+
document = Kochab.parse(File.read(path))
|
|
69
|
+
@errors.concat(document.errors)
|
|
70
|
+
raise Error, "invalid settings: #{path}" unless document.valid?
|
|
71
|
+
document.value
|
|
72
|
+
end
|
|
73
|
+
def merge(left, right)
|
|
74
|
+
left.merge(right) { |_, old, new| old.is_a?(Hash) && new.is_a?(Hash) ? merge(old, new) : new }
|
|
75
|
+
end
|
|
76
|
+
def validate!
|
|
77
|
+
{"font_size" => 6..96, "tab_size" => 1..16, "scroll_friction" => 0..100}.each do |key, range|
|
|
78
|
+
value = @values[key]
|
|
79
|
+
raise Error, "invalid #{key}" unless value.is_a?(Numeric) && range.cover?(value)
|
|
80
|
+
end
|
|
81
|
+
raise Error, "tab_size must be an integer" unless @values["tab_size"].is_a?(Integer)
|
|
82
|
+
%w[soft_wrap vim_mode use_tabs].each { |key| raise Error, "#{key} must be true or false" unless [true, false].include?(@values[key]) }
|
|
83
|
+
validate_keymap!
|
|
84
|
+
%w[languages language_servers].each { |key| raise Error, "#{key} must be an object" unless @values[key].is_a?(Hash) }
|
|
85
|
+
raise Error, "theme must be a string" unless @values["theme"].is_a?(String)
|
|
86
|
+
raise Error, "font_family must be a string or null" unless @values["font_family"].nil? || @values["font_family"].is_a?(String)
|
|
87
|
+
raise Error, "icon_theme must be a string or null" unless @values["icon_theme"].nil? || @values["icon_theme"].is_a?(String)
|
|
88
|
+
@values["languages"] = @values["languages"].to_h do |name, layer|
|
|
89
|
+
raise Error, "language settings must be objects" unless name.is_a?(String) && layer.is_a?(Hash)
|
|
90
|
+
raise Error, "language settings cannot contain nested languages" if layer.key?("languages")
|
|
91
|
+
checked = Settings.new(@values.merge("languages" => {}), layer)
|
|
92
|
+
[name, layer.key?("keymap") ? layer.merge("keymap" => checked["keymap"]) : layer]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
def validate_keymap!
|
|
96
|
+
groups = @values["keymap"]
|
|
97
|
+
raise Error, "keymap must be an array of at most 128 groups" unless groups.is_a?(Array) && groups.length <= 128
|
|
98
|
+
count = 0
|
|
99
|
+
@values["keymap"] = groups.map do |group|
|
|
100
|
+
raise Error, "keymap groups require a bindings object" unless group.is_a?(Hash) && group["bindings"].is_a?(Hash)
|
|
101
|
+
context = group.fetch("context", "")
|
|
102
|
+
raise Error, "keymap context must be a string of at most 256 bytes" unless context.is_a?(String) && context.bytesize <= 256
|
|
103
|
+
Zaniah::Input::ContextPredicate.new(context)
|
|
104
|
+
bindings = group["bindings"].to_h do |keys, action|
|
|
105
|
+
count += 1
|
|
106
|
+
raise Error, "keymap exceeds 1024 bindings" if count > 1024
|
|
107
|
+
raise Error, "invalid keymap key sequence" unless keys.is_a?(String) && keys.bytesize.between?(1, 256) && !keys.strip.empty?
|
|
108
|
+
keys.split.each { |key| Zaniah::Input::Keystroke.normalize(key) }
|
|
109
|
+
raise Error, "keymap action must be a string or null" unless action.nil? || (action.is_a?(String) && action.bytesize.between?(1, 256))
|
|
110
|
+
[keys.dup.freeze, action&.dup&.freeze]
|
|
111
|
+
end.freeze
|
|
112
|
+
{"context" => context.dup.freeze, "bindings" => bindings}.freeze
|
|
113
|
+
end.freeze
|
|
114
|
+
rescue ArgumentError => error
|
|
115
|
+
raise Error, "invalid keymap: #{error.message}"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Deliberately uses bounded Ruby Regexp, never eval or an external JS runtime.
|
|
4
|
+
# Supported syntax and JS portability boundaries are in docs/snippets.md.
|
|
5
|
+
class Canopus::Snippet::Transform
|
|
6
|
+
TIMEOUT = 0.05
|
|
7
|
+
SPACE = '\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff'
|
|
8
|
+
CASES = %w[upcase downcase capitalize camelcase pascalcase snakecase kebabcase].freeze
|
|
9
|
+
Format = Data.define(:index, :operation, :positive, :negative)
|
|
10
|
+
private_constant :Format
|
|
11
|
+
attr_reader :pattern, :flags
|
|
12
|
+
|
|
13
|
+
def self.parse(scanner, depth)
|
|
14
|
+
pattern = +""
|
|
15
|
+
closed = false
|
|
16
|
+
until scanner.eos?
|
|
17
|
+
character = scanner.getch
|
|
18
|
+
if character == "\\"
|
|
19
|
+
following = scanner.getch
|
|
20
|
+
raise Canopus::Error, "unclosed snippet transform" unless following
|
|
21
|
+
pattern << (following == "/" ? "/" : "\\#{following}")
|
|
22
|
+
elsif character == "/"
|
|
23
|
+
closed = true
|
|
24
|
+
break
|
|
25
|
+
else
|
|
26
|
+
pattern << character
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
raise Canopus::Error, "unclosed snippet transform pattern" unless closed
|
|
30
|
+
replacement = parse_format(scanner, ["/"], depth)
|
|
31
|
+
raise Canopus::Error, "unclosed snippet transform replacement" unless scanner.getch == "/"
|
|
32
|
+
flags = scanner.scan(/[A-Za-z]*/)
|
|
33
|
+
raise Canopus::Error, "unclosed snippet transform" unless scanner.getch == "}"
|
|
34
|
+
new(pattern, replacement, flags)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.parse_format(scanner, endings, depth, budget = [0])
|
|
38
|
+
raise Canopus::Error, "snippet format nesting exceeds #{Canopus::Snippet::MAX_DEPTH}" if depth > Canopus::Snippet::MAX_DEPTH
|
|
39
|
+
nodes, literal = [], +""
|
|
40
|
+
until scanner.eos? || endings.include?(scanner.peek(1))
|
|
41
|
+
character = scanner.getch
|
|
42
|
+
if character == "\\" && scanner.peek(1).match?(/[\\\/$}:]/)
|
|
43
|
+
literal << scanner.getch
|
|
44
|
+
elsif character == "$"
|
|
45
|
+
braced = !!scanner.scan(/\{/)
|
|
46
|
+
number = scanner.scan(/\d+/)
|
|
47
|
+
unless number
|
|
48
|
+
raise Canopus::Error, "invalid snippet transform capture" if braced
|
|
49
|
+
literal << "$"
|
|
50
|
+
next
|
|
51
|
+
end
|
|
52
|
+
raise Canopus::Error, "snippet capture index is too large" if number.length > 7 || number.to_i > 1_000_000
|
|
53
|
+
nodes << literal.freeze unless literal.empty?
|
|
54
|
+
literal = +""
|
|
55
|
+
operation, positive, negative = :capture, [], []
|
|
56
|
+
budget[0] += 1
|
|
57
|
+
raise Canopus::Error, "too many snippet format captures" if budget[0] > Canopus::Snippet::MAX_NODES
|
|
58
|
+
if braced && scanner.scan(/:/)
|
|
59
|
+
operation_start = scanner.pos
|
|
60
|
+
case scanner.getch
|
|
61
|
+
when "/"
|
|
62
|
+
operation = scanner.scan(/[a-z]+/)
|
|
63
|
+
raise Canopus::Error, "unsupported snippet case conversion" unless CASES.include?(operation)
|
|
64
|
+
when "+"
|
|
65
|
+
operation = :if
|
|
66
|
+
positive = parse_format(scanner, ["}"], depth + 1, budget)
|
|
67
|
+
when "?"
|
|
68
|
+
operation = :conditional
|
|
69
|
+
positive = parse_format(scanner, [":", "}"], depth + 1, budget)
|
|
70
|
+
raise Canopus::Error, "snippet conditional requires ':'" unless scanner.getch == ":"
|
|
71
|
+
negative = parse_format(scanner, ["}"], depth + 1, budget)
|
|
72
|
+
when "-"
|
|
73
|
+
operation = :else
|
|
74
|
+
negative = parse_format(scanner, ["}"], depth + 1, budget)
|
|
75
|
+
else
|
|
76
|
+
scanner.pos = operation_start
|
|
77
|
+
operation = :else
|
|
78
|
+
negative = parse_format(scanner, ["}"], depth + 1, budget)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
raise Canopus::Error, "unclosed snippet transform format" if braced && scanner.getch != "}"
|
|
82
|
+
nodes << Format.new(number.to_i, operation, positive.freeze, negative.freeze)
|
|
83
|
+
else
|
|
84
|
+
literal << character
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
nodes << literal.freeze unless literal.empty?
|
|
88
|
+
nodes.freeze
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def initialize(pattern, replacement, flags)
|
|
92
|
+
raise Canopus::Error, "unsupported snippet transform flags: #{flags}" unless flags.match?(/\A[gimsu]*\z/) && flags.chars.uniq.length == flags.length
|
|
93
|
+
raise Canopus::Error, "snippet transform pattern is too large" if pattern.bytesize > 65_536
|
|
94
|
+
@pattern, @flags, @replacement = pattern.freeze, flags.freeze, replacement
|
|
95
|
+
# Reject Ruby-only syntax and constructs with incompatible JS semantics.
|
|
96
|
+
translated, in_class, scanner, groups = +"", false, StringScanner.new(pattern), []
|
|
97
|
+
until scanner.eos?
|
|
98
|
+
character = scanner.getch
|
|
99
|
+
if character == "\\"
|
|
100
|
+
following = scanner.getch
|
|
101
|
+
raise Canopus::Error, "incomplete snippet regular expression escape" unless following
|
|
102
|
+
raise Canopus::Error, "unsupported snippet regular expression escape: \\#{following}" if following.match?(/[AGKRXZzghkpPc1-9]/) || (following == "u" && flags.include?("i"))
|
|
103
|
+
translated << case following
|
|
104
|
+
when "s" then in_class ? SPACE : "[#{SPACE}]"
|
|
105
|
+
when "S" then "[^#{SPACE}]"
|
|
106
|
+
when "b" then in_class ? "\\b" : '(?:(?<=\w)(?!\w)|(?<!\w)(?=\w))'
|
|
107
|
+
when "B"
|
|
108
|
+
@nonword_boundary = true unless in_class
|
|
109
|
+
in_class ? "B" : '(?:(?<=\w)(?=\w)|(?<!\w)(?!\w))'
|
|
110
|
+
else character + following
|
|
111
|
+
end
|
|
112
|
+
else
|
|
113
|
+
if !in_class && ((character == "(" && scanner.peek(1) == "?" && !scanner.rest.match?(/\A\?(?:[:=!]|<[=!])/)) || (character.match?(/[*+?}]/) && scanner.peek(1) == "+"))
|
|
114
|
+
raise Canopus::Error, "unsupported snippet regular expression construct"
|
|
115
|
+
end
|
|
116
|
+
raise Canopus::Error, "unsupported snippet character-class intersection" if in_class && character == "&" && scanner.peek(1) == "&"
|
|
117
|
+
if !in_class && character == "("
|
|
118
|
+
capture = scanner.peek(1) != "?"
|
|
119
|
+
groups.map! { |parent| parent || capture }
|
|
120
|
+
groups << capture
|
|
121
|
+
raise Canopus::Error, "snippet regular expression nesting exceeds #{Canopus::Snippet::MAX_DEPTH}" if groups.length > Canopus::Snippet::MAX_DEPTH
|
|
122
|
+
elsif !in_class && character == ")"
|
|
123
|
+
captures = groups.pop
|
|
124
|
+
raise Canopus::Error, "captures inside repeated groups are not portable" if captures && scanner.peek(1).match?(/[*+{]/)
|
|
125
|
+
end
|
|
126
|
+
translated << case character
|
|
127
|
+
when "[" then if in_class then "\\[" else in_class = true; character end
|
|
128
|
+
when "]" then in_class = false; character
|
|
129
|
+
when "^" then in_class ? character : flags.include?("m") ? '(?:\A|(?<=[\r\n\u2028\u2029]))' : '\A'
|
|
130
|
+
when "$" then in_class ? character : flags.include?("m") ? '(?=\z|[\r\n\u2028\u2029])' : '\z'
|
|
131
|
+
when "." then !in_class && !flags.include?("s") ? "[^\\n\\r\\u2028\\u2029]" : character
|
|
132
|
+
else character
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
options = (flags.include?("i") ? Regexp::IGNORECASE : 0) | (flags.include?("s") ? Regexp::MULTILINE : 0)
|
|
137
|
+
@regexp = Regexp.new(translated, options, timeout: TIMEOUT)
|
|
138
|
+
freeze
|
|
139
|
+
rescue RegexpError => error
|
|
140
|
+
raise Canopus::Error, "invalid snippet regular expression: #{error.message}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def apply(value)
|
|
144
|
+
raise Canopus::Error, "snippet transform input exceeds output limit" if value.bytesize > Canopus::Snippet::MAX_OUTPUT
|
|
145
|
+
astral = value.match?(/[\u{10000}-\u{10ffff}]/) || @pattern.match?(/[\u{10000}-\u{10ffff}]/)
|
|
146
|
+
raise Canopus::Error, "astral Unicode snippet transforms require the 'u' flag" if astral && !@flags.include?("u")
|
|
147
|
+
raise Canopus::Error, "\\B with astral Unicode is not portable between JavaScript and Ruby" if astral && @nonword_boundary
|
|
148
|
+
if @flags.include?("i") && [value, @pattern].any? { |source| source.each_char.any? { |character| !character.ascii_only? && ((folded = character.downcase(:fold)).length != 1 || folded.ascii_only?) } }
|
|
149
|
+
raise Canopus::Error, "Unicode case folding into ASCII or multiple characters is not portable"
|
|
150
|
+
end
|
|
151
|
+
output, cursor, count, matched = +"", 0, 0, false
|
|
152
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + TIMEOUT
|
|
153
|
+
Canopus.with_regexp_timeout(@regexp) do
|
|
154
|
+
value.to_enum(:scan, @regexp).each do
|
|
155
|
+
match = Regexp.last_match
|
|
156
|
+
matched = true
|
|
157
|
+
count += 1
|
|
158
|
+
raise Canopus::Error, "snippet transform exceeded execution limit" if count > Canopus::Snippet::MAX_NODES || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
159
|
+
first, last = match.byteoffset(0)
|
|
160
|
+
append(output, value.byteslice(cursor...first))
|
|
161
|
+
append(output, format(@replacement, match))
|
|
162
|
+
cursor = last
|
|
163
|
+
break unless @flags.include?("g")
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
return format(@replacement, nil) if !matched && fallback?(@replacement)
|
|
167
|
+
append(output, value.byteslice(cursor..))
|
|
168
|
+
output
|
|
169
|
+
rescue Regexp::TimeoutError
|
|
170
|
+
raise Canopus::Error, "snippet regular expression timed out"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
def append(output, text)
|
|
176
|
+
raise Canopus::Error, "snippet transform exceeds output limit" if output.bytesize + text.bytesize > Canopus::Snippet::MAX_OUTPUT
|
|
177
|
+
output << text
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def fallback?(nodes)
|
|
181
|
+
nodes.any? { |node| node.is_a?(Format) && ([:else, :conditional].include?(node.operation) || fallback?(node.positive) || fallback?(node.negative)) }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def format(nodes, match)
|
|
185
|
+
nodes.each_with_object(+"") do |node, output|
|
|
186
|
+
if node.is_a?(String)
|
|
187
|
+
append(output, node)
|
|
188
|
+
next
|
|
189
|
+
end
|
|
190
|
+
value = (match && node.index < match.length ? match[node.index] : nil).to_s
|
|
191
|
+
replacement = case node.operation
|
|
192
|
+
when :capture then value
|
|
193
|
+
when :if then value.empty? ? "" : format(node.positive, match)
|
|
194
|
+
when :conditional then format(value.empty? ? node.negative : node.positive, match)
|
|
195
|
+
when :else then value.empty? ? format(node.negative, match) : value
|
|
196
|
+
when "upcase" then value.upcase
|
|
197
|
+
when "downcase" then value.downcase
|
|
198
|
+
when "capitalize" then value.sub(/\A./m) { |first| first.upcase }
|
|
199
|
+
when "camelcase", "pascalcase"
|
|
200
|
+
words = value.scan(/[\p{L}\p{N}]+/)
|
|
201
|
+
words.each_with_index.map { |word, index| word.sub(/\A./m) { |first| index.zero? && node.operation == "camelcase" ? first.downcase : first.upcase } }.join
|
|
202
|
+
when "snakecase" then value.gsub(/([\p{Ll}\d])([\p{Lu}])/, '\1_\2').gsub(/[\s-]+/, "_").downcase
|
|
203
|
+
when "kebabcase"
|
|
204
|
+
value.gsub(/([\p{Lu}]+)([\p{Lu}][\p{Ll}])/, '\1-\2').gsub(/([\p{Ll}\d])([\p{Lu}])/, '\1-\2').gsub(/[^\p{L}\p{N}]+/, "-").sub(/\A-/, "").sub(/-\z/, "").downcase
|
|
205
|
+
end
|
|
206
|
+
append(output, replacement)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
|
|
5
|
+
module Canopus
|
|
6
|
+
# LSP/TextMate snippets. All exported offsets are UTF-8 byte offsets.
|
|
7
|
+
class Snippet
|
|
8
|
+
MAX_SOURCE = 1 << 20
|
|
9
|
+
MAX_OUTPUT = 4 << 20
|
|
10
|
+
MAX_DEPTH = 32
|
|
11
|
+
MAX_NODES = 10_000
|
|
12
|
+
VARIABLES = %w[TM_SELECTED_TEXT TM_CURRENT_LINE TM_CURRENT_WORD TM_LINE_INDEX TM_LINE_NUMBER
|
|
13
|
+
TM_FILENAME TM_FILENAME_BASE TM_DIRECTORY TM_FILEPATH RELATIVE_FILEPATH CLIPBOARD
|
|
14
|
+
WORKSPACE_NAME WORKSPACE_FOLDER CURSOR_INDEX CURSOR_NUMBER CURRENT_YEAR CURRENT_YEAR_SHORT
|
|
15
|
+
CURRENT_MONTH CURRENT_MONTH_NAME CURRENT_MONTH_NAME_SHORT CURRENT_DATE CURRENT_DAY_NAME
|
|
16
|
+
CURRENT_DAY_NAME_SHORT CURRENT_HOUR CURRENT_MINUTE CURRENT_SECOND CURRENT_MILLISECOND
|
|
17
|
+
CURRENT_SECONDS_UNIX CURRENT_MILLISECONDS_UNIX CURRENT_TIMEZONE_OFFSET CURRENT_TIMEZONE_NAME
|
|
18
|
+
RANDOM RANDOM_HEX UUID BLOCK_COMMENT_START BLOCK_COMMENT_END LINE_COMMENT].freeze
|
|
19
|
+
Occurrence = Data.define(:index, :range, :transform, :parents)
|
|
20
|
+
Node = Struct.new(:index, :children, :choices, :transform)
|
|
21
|
+
private_constant :Node
|
|
22
|
+
attr_reader :text, :tabstops, :choices, :transforms, :occurrences
|
|
23
|
+
|
|
24
|
+
def initialize(source, variables: {})
|
|
25
|
+
raise Error, "snippet must be valid UTF-8" unless source.is_a?(String) && source.valid_encoding? && (source.encoding == Encoding::UTF_8 || (source.encoding.ascii_compatible? && source.ascii_only?))
|
|
26
|
+
raise Error, "snippet exceeds #{MAX_SOURCE} bytes" if source.bytesize > MAX_SOURCE
|
|
27
|
+
raise Error, "snippet variables must be a Hash" unless variables.is_a?(Hash)
|
|
28
|
+
@variables = variables.to_h do |key, value|
|
|
29
|
+
raise Error, "snippet variable values must be UTF-8 strings or nil" unless value.nil? || (value.is_a?(String) && value.valid_encoding? && (value.encoding == Encoding::UTF_8 || (value.encoding.ascii_compatible? && value.ascii_only?)))
|
|
30
|
+
raise Error, "snippet variable exceeds output limit" if value && value.bytesize > MAX_OUTPUT
|
|
31
|
+
[key.to_s, value&.encode(Encoding::UTF_8)]
|
|
32
|
+
end
|
|
33
|
+
@scanner, @node_count, @maximum_index = StringScanner.new(source), 0, 0
|
|
34
|
+
nodes = parse
|
|
35
|
+
nodes = resolve_variables(nodes)
|
|
36
|
+
@defaults, @choices = {}, {}
|
|
37
|
+
collect_defaults(nodes)
|
|
38
|
+
@text, @tabstops, @transforms = +"", Hash.new { |h, k| h[k] = [] }, []
|
|
39
|
+
@occurrences = []
|
|
40
|
+
render(nodes)
|
|
41
|
+
@text.freeze
|
|
42
|
+
@tabstops = @tabstops.sort_by { |index, _| index.zero? ? Float::INFINITY : index }.to_h.transform_values { |ranges| ranges.freeze }.freeze
|
|
43
|
+
@choices.freeze
|
|
44
|
+
@transforms.freeze
|
|
45
|
+
@occurrences.freeze
|
|
46
|
+
# The parse tree and variable values are not retained by a live editor.
|
|
47
|
+
@scanner = @variables = @defaults = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def parse(ending = nil, depth = 0)
|
|
53
|
+
raise Error, "snippet nesting exceeds #{MAX_DEPTH}" if depth > MAX_DEPTH
|
|
54
|
+
nodes, literal = [], +""
|
|
55
|
+
until @scanner.eos?
|
|
56
|
+
character = @scanner.getch
|
|
57
|
+
if character == ending
|
|
58
|
+
nodes << literal unless literal.empty?
|
|
59
|
+
return nodes
|
|
60
|
+
elsif character == "\\" && @scanner.peek(1).match?(/[\\$}]/)
|
|
61
|
+
literal << @scanner.getch
|
|
62
|
+
elsif character == "$"
|
|
63
|
+
braced = !!@scanner.scan(/\{/)
|
|
64
|
+
identifier = @scanner.scan(/\d+|[A-Za-z_][A-Za-z_0-9]*/)
|
|
65
|
+
if !identifier && !braced
|
|
66
|
+
literal << "$"
|
|
67
|
+
next
|
|
68
|
+
end
|
|
69
|
+
raise Error, "invalid snippet identifier" unless identifier
|
|
70
|
+
nodes << literal unless literal.empty?
|
|
71
|
+
literal = +""
|
|
72
|
+
numbered = identifier.match?(/\A\d+\z/)
|
|
73
|
+
raise Error, "snippet tabstop index is too long" if numbered && identifier.length > 7
|
|
74
|
+
index = numbered ? Integer(identifier, 10) : identifier
|
|
75
|
+
raise Error, "snippet tabstop index is too large" if index.is_a?(Integer) && index > 1_000_000
|
|
76
|
+
@maximum_index = [@maximum_index, index].max if index.is_a?(Integer)
|
|
77
|
+
node = Node.new(index)
|
|
78
|
+
@node_count += 1
|
|
79
|
+
raise Error, "too many snippet nodes" if @node_count > MAX_NODES
|
|
80
|
+
if braced
|
|
81
|
+
case @scanner.getch
|
|
82
|
+
when "}" # Bare tabstop or variable.
|
|
83
|
+
when ":" then node.children = parse("}", depth + 1)
|
|
84
|
+
when "|"
|
|
85
|
+
raise Error, "choices require a numbered tabstop" unless index.is_a?(Integer)
|
|
86
|
+
node.choices = parse_choices
|
|
87
|
+
node.children = [node.choices.first]
|
|
88
|
+
when "/" then node.transform = Transform.parse(@scanner, depth + 1)
|
|
89
|
+
else raise Error, "invalid or unclosed snippet placeholder"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
nodes << node
|
|
93
|
+
else
|
|
94
|
+
literal << character
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
raise Error, "unclosed snippet placeholder" if ending
|
|
98
|
+
nodes << literal unless literal.empty?
|
|
99
|
+
nodes
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def parse_choices
|
|
103
|
+
choices, value = [], +""
|
|
104
|
+
until @scanner.eos?
|
|
105
|
+
character = @scanner.getch
|
|
106
|
+
if character == "\\" && @scanner.peek(1).match?(/[\\,|]/)
|
|
107
|
+
value << @scanner.getch
|
|
108
|
+
elsif character == ","
|
|
109
|
+
choices << value.freeze
|
|
110
|
+
value = +""
|
|
111
|
+
elsif character == "|" && @scanner.scan(/\}/)
|
|
112
|
+
choices << value.freeze
|
|
113
|
+
return choices.freeze
|
|
114
|
+
else
|
|
115
|
+
value << character
|
|
116
|
+
end
|
|
117
|
+
raise Error, "too many snippet choices" if choices.length >= MAX_NODES
|
|
118
|
+
end
|
|
119
|
+
raise Error, "unclosed snippet choice"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def resolve_variables(nodes)
|
|
123
|
+
nodes.flat_map do |node|
|
|
124
|
+
next node if node.is_a?(String)
|
|
125
|
+
if node.index.is_a?(Integer)
|
|
126
|
+
node.children = resolve_variables(node.children) if node.children
|
|
127
|
+
[node]
|
|
128
|
+
elsif node.transform
|
|
129
|
+
[node.transform.apply(@variables[node.index] || "")]
|
|
130
|
+
elsif @variables[node.index]
|
|
131
|
+
[@variables[node.index]]
|
|
132
|
+
elsif node.children
|
|
133
|
+
resolve_variables(node.children)
|
|
134
|
+
elsif @variables.key?(node.index) || VARIABLES.include?(node.index)
|
|
135
|
+
[""]
|
|
136
|
+
else
|
|
137
|
+
@maximum_index += 1
|
|
138
|
+
[Node.new(@maximum_index, [node.index])]
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def collect_defaults(nodes)
|
|
144
|
+
nodes.each do |node|
|
|
145
|
+
next if node.is_a?(String)
|
|
146
|
+
@defaults[node.index] ||= node.children if node.children && !node.children.empty?
|
|
147
|
+
@choices[node.index] ||= node.choices if node.choices
|
|
148
|
+
collect_defaults(node.children) if node.children
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def render(nodes, ancestors = [])
|
|
153
|
+
nodes.each do |node|
|
|
154
|
+
if node.is_a?(String)
|
|
155
|
+
raise Error, "expanded snippet exceeds #{MAX_OUTPUT} bytes" if @text.bytesize + node.bytesize > MAX_OUTPUT
|
|
156
|
+
@text << node
|
|
157
|
+
next
|
|
158
|
+
end
|
|
159
|
+
raise Error, "cyclic snippet placeholder #{node.index}" if ancestors.include?(node.index)
|
|
160
|
+
raise Error, "expanded snippet nesting exceeds #{MAX_DEPTH}" if ancestors.length >= MAX_DEPTH
|
|
161
|
+
start = @text.bytesize
|
|
162
|
+
# Transformed mirrors start with the placeholder value, and change on Tab.
|
|
163
|
+
# Their nested fields are not independent editable tabstops.
|
|
164
|
+
previous_tabstops, previous_transforms, previous_occurrences = @tabstops, @transforms, @occurrences if node.transform
|
|
165
|
+
@tabstops, @transforms, @occurrences = Hash.new { |h, k| h[k] = [] }, [], [] if node.transform
|
|
166
|
+
render(@defaults[node.index] || [], [*ancestors, node.index])
|
|
167
|
+
@tabstops, @transforms, @occurrences = previous_tabstops, previous_transforms, previous_occurrences if node.transform
|
|
168
|
+
range = (start...@text.bytesize).freeze
|
|
169
|
+
occurrence = Occurrence.new(node.index, range, node.transform, ancestors.freeze)
|
|
170
|
+
@occurrences << occurrence
|
|
171
|
+
if node.transform
|
|
172
|
+
@transforms << occurrence
|
|
173
|
+
else
|
|
174
|
+
@tabstops[node.index] << range
|
|
175
|
+
end
|
|
176
|
+
@rendered_nodes = (@rendered_nodes || 0) + 1
|
|
177
|
+
raise Error, "too many expanded snippet nodes" if @rendered_nodes > MAX_NODES
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
require_relative "snippet/transform"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
class TabMap
|
|
5
|
+
attr_reader :tab_size
|
|
6
|
+
def initialize(tab_size: 4)
|
|
7
|
+
raise ArgumentError, "tab size must be positive" unless tab_size.is_a?(Integer) && tab_size.positive?
|
|
8
|
+
@tab_size = tab_size
|
|
9
|
+
end
|
|
10
|
+
def transform(text, offsets, checkpoint: nil)
|
|
11
|
+
output, positions, column, i = +"", [offsets.first], 0, 0
|
|
12
|
+
text.each_grapheme_cluster do |char|
|
|
13
|
+
checkpoint&.call if (i & 1023).zero?
|
|
14
|
+
if char == "\t"
|
|
15
|
+
count = @tab_size - column % @tab_size
|
|
16
|
+
output << " " * count
|
|
17
|
+
(count - 1).times { positions << offsets[i] }
|
|
18
|
+
positions << offsets[i + 1]
|
|
19
|
+
column += count
|
|
20
|
+
else
|
|
21
|
+
output << char
|
|
22
|
+
char.length.times { |index| positions << offsets[i + index + 1] }
|
|
23
|
+
column += char.ascii_only? ? char.length : Zaniah::Unicode.width(char)
|
|
24
|
+
end
|
|
25
|
+
i += char.length
|
|
26
|
+
end
|
|
27
|
+
[output, positions]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|