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,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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Terminal
5
+ Cell = Struct.new(:text, :width, :foreground, :background, :attributes, :hyperlink, keyword_init: true)
6
+ end
7
+ end