canopus 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +191 -19
  4. data/assets/keymaps/emacs.jsonc +5 -0
  5. data/assets/keymaps/jetbrains.jsonc +5 -0
  6. data/assets/keymaps/sublime.jsonc +5 -0
  7. data/assets/keymaps/vscode.jsonc +5 -0
  8. data/docs/debugging.md +143 -0
  9. data/docs/distribution.md +40 -9
  10. data/docs/lsp.md +131 -0
  11. data/docs/performance.md +18 -0
  12. data/docs/providers.md +36 -0
  13. data/docs/release-log.md +12 -0
  14. data/docs/tasks.md +64 -0
  15. data/docs/testing.md +49 -0
  16. data/docs/vim.md +3 -0
  17. data/exe/canopus +4 -1
  18. data/lib/canopus/buffer.rb +291 -50
  19. data/lib/canopus/cli.rb +5 -2
  20. data/lib/canopus/command.rb +13 -4
  21. data/lib/canopus/controller.rb +183 -40
  22. data/lib/canopus/debug/breakpoints.rb +550 -0
  23. data/lib/canopus/debug/configuration.rb +291 -0
  24. data/lib/canopus/debug/console.rb +243 -0
  25. data/lib/canopus/debug/panel.rb +487 -0
  26. data/lib/canopus/debug/session.rb +354 -0
  27. data/lib/canopus/decoration.rb +4 -2
  28. data/lib/canopus/denebola_compat.rb +27 -0
  29. data/lib/canopus/diagnostics.rb +173 -0
  30. data/lib/canopus/display_map/overlay_map.rb +6 -2
  31. data/lib/canopus/editor/snippet_expandable.rb +3 -2
  32. data/lib/canopus/editor.rb +122 -31
  33. data/lib/canopus/git/state.rb +100 -0
  34. data/lib/canopus/language/background_analysis.rb +93 -14
  35. data/lib/canopus/language/document.rb +70 -34
  36. data/lib/canopus/language/symbol.rb +1 -0
  37. data/lib/canopus/language/syntax_worker.rb +61 -9
  38. data/lib/canopus/language.rb +3 -0
  39. data/lib/canopus/minimap.rb +201 -0
  40. data/lib/canopus/patch.rb +3 -1
  41. data/lib/canopus/plugins/api.rb +4 -2
  42. data/lib/canopus/plugins/isolated_runtime.rb +64 -7
  43. data/lib/canopus/plugins/registry.rb +114 -3
  44. data/lib/canopus/plugins.rb +8 -7
  45. data/lib/canopus/project.rb +15 -6
  46. data/lib/canopus/provider.rb +192 -0
  47. data/lib/canopus/regexp_compat.rb +36 -4
  48. data/lib/canopus/selection.rb +12 -0
  49. data/lib/canopus/settings/schema.rb +201 -0
  50. data/lib/canopus/settings.rb +328 -49
  51. data/lib/canopus/task/configuration.rb +356 -0
  52. data/lib/canopus/task/problem_matcher.rb +264 -0
  53. data/lib/canopus/task/runner.rb +314 -0
  54. data/lib/canopus/test_runner/bounded_ignore.rb +144 -0
  55. data/lib/canopus/test_runner/discovery.rb +97 -0
  56. data/lib/canopus/test_runner/execution.rb +194 -0
  57. data/lib/canopus/test_runner/minitest.rb +116 -0
  58. data/lib/canopus/test_runner/rspec.rb +103 -0
  59. data/lib/canopus/test_runner/safe_walker.rb +37 -0
  60. data/lib/canopus/test_runner.rb +54 -0
  61. data/lib/canopus/theme/import.rb +131 -0
  62. data/lib/canopus/theme.rb +28 -4
  63. data/lib/canopus/version.rb +1 -1
  64. data/lib/canopus/vim/motionable.rb +1 -1
  65. data/lib/canopus/vim.rb +10 -16
  66. data/lib/canopus/workspace/auto_savable.rb +83 -0
  67. data/lib/canopus/workspace/debug_aware.rb +600 -0
  68. data/lib/canopus/workspace/edit/plan.rb +2 -2
  69. data/lib/canopus/workspace/editor_configurable.rb +113 -0
  70. data/lib/canopus/workspace/file_change_aware.rb +11 -3
  71. data/lib/canopus/workspace/git_aware.rb +80 -34
  72. data/lib/canopus/workspace/git_blame.rb +175 -0
  73. data/lib/canopus/workspace/git_conflict_resolution.rb +410 -0
  74. data/lib/canopus/workspace/git_diff_view.rb +641 -0
  75. data/lib/canopus/workspace/git_history.rb +171 -0
  76. data/lib/canopus/workspace/git_remote.rb +267 -0
  77. data/lib/canopus/workspace/git_staging.rb +583 -0
  78. data/lib/canopus/workspace/hierarchy_aware.rb +477 -0
  79. data/lib/canopus/workspace/keymap_aware.rb +66 -0
  80. data/lib/canopus/workspace/language_aware.rb +3721 -135
  81. data/lib/canopus/workspace/language_server_configurable.rb +287 -71
  82. data/lib/canopus/workspace/persistent_undo.rb +169 -0
  83. data/lib/canopus/workspace/problems_aware.rb +131 -0
  84. data/lib/canopus/workspace/project_searchable.rb +88 -11
  85. data/lib/canopus/workspace/project_tree_editable.rb +3 -1
  86. data/lib/canopus/workspace/recoverable.rb +204 -0
  87. data/lib/canopus/workspace/session_persistable.rb +85 -13
  88. data/lib/canopus/workspace/settings_aware.rb +76 -2
  89. data/lib/canopus/workspace/task_aware.rb +194 -0
  90. data/lib/canopus/workspace/test_aware.rb +394 -0
  91. data/lib/canopus/workspace/trust.rb +73 -0
  92. data/lib/canopus/workspace/view/terminal_presentable.rb +298 -47
  93. data/lib/canopus/workspace/view.rb +399 -74
  94. data/lib/canopus/workspace.rb +688 -44
  95. data/lib/canopus.rb +20 -2
  96. data/sig/canopus.rbs +138 -31
  97. data/sig/controller.rbs +10 -3
  98. data/sig/debug.rbs +121 -0
  99. data/sig/decoration.rbs +12 -2
  100. data/sig/diagnostics.rbs +26 -0
  101. data/sig/minimap.rbs +20 -0
  102. data/sig/provider.rbs +29 -0
  103. data/sig/services.rbs +1 -16
  104. data/sig/task.rbs +92 -0
  105. data/sig/test_runner.rbs +109 -0
  106. data/sig/trust.rbs +17 -0
  107. data/sig/workspace_edits.rbs +2 -2
  108. data/sig/workspace_services.rbs +234 -2
  109. data/tools/check_dependencies.rb +21 -2
  110. data/tools/package.rb +172 -14
  111. data/tools/package_signature_test.rb +33 -0
  112. data/tools/package_test.rb +29 -0
  113. data/tools/sign_package.rb +123 -0
  114. data/tools/update.rb +189 -0
  115. data/tools/update_test.rb +71 -0
  116. metadata +206 -31
  117. data/lib/canopus/lsp/client.rb +0 -307
  118. data/lib/canopus/lsp/error.rb +0 -7
  119. data/lib/canopus/lsp/future/subscription.rb +0 -9
  120. data/lib/canopus/lsp/future.rb +0 -87
  121. data/lib/canopus/lsp/protocol.rb +0 -83
  122. data/lib/canopus/lsp/server_error.rb +0 -13
  123. data/lib/canopus/lsp/timeout.rb +0 -7
  124. data/lib/canopus/lsp/transport.rb +0 -123
  125. data/lib/canopus/lsp.rb +0 -19
  126. data/lib/canopus/project/search.rb +0 -164
  127. data/lib/canopus/project/search_worker/cancelled.rb +0 -3
  128. data/lib/canopus/project/search_worker/runner.rb +0 -9
  129. data/lib/canopus/project/search_worker.rb +0 -108
  130. data/lib/canopus/terminal/cell.rb +0 -7
  131. data/lib/canopus/terminal/grid.rb +0 -321
  132. data/lib/canopus/terminal/pty.rb +0 -178
  133. data/lib/canopus/terminal/scrollback.rb +0 -38
  134. data/lib/canopus/terminal/vt.rb +0 -398
  135. data/lib/canopus/terminal.rb +0 -13
  136. data/sig/lsp.rbs +0 -99
  137. data/sig/terminal.rbs +0 -130
@@ -1,68 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "fileutils"
5
+ require "tempfile"
4
6
  require "kochab"
7
+ require_relative "settings/schema"
5
8
 
6
9
  module Canopus
7
10
  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" => {},
11
- "tabs" => {"activate_on_close" => "history", "close_on_middle_click" => true, "close_empty_pane" => true,
12
- "reopen_history_limit" => 20, "confirm_on_close_dirty" => true}.freeze,
13
- "dock" => {"left" => {"size" => 220, "visible" => true}.freeze,
14
- "right" => {"size" => 260, "visible" => false}.freeze,
15
- "bottom" => {"size" => 280, "visible" => false}.freeze,
16
- "panels" => {"explorer" => {"size" => 220, "visible" => true}.freeze,
17
- "search" => {"size" => 220, "visible" => false}.freeze,
18
- "terminal" => {"size" => 280, "visible" => false}.freeze}.freeze}.freeze,
19
- "terminal" => {"shell" => nil, "working_directory" => "project", "env" => {}.freeze, "scrollback_lines" => 10_000,
20
- "font_size" => nil, "line_height" => 1.2, "copy_on_select" => false, "blinking" => "terminal_controlled", "cursor_shape" => "block",
21
- "close_on_exit" => "clean", "confirm_close_running" => true, "confirm_multiline_paste" => true,
22
- "restore_on_startup" => false, "hide_when_empty" => false, "max_bytes_per_frame" => 262_144,
23
- "queue_limit_bytes" => 8_388_608, "resize_debounce_ms" => 100, "min_rows" => 4, "min_cols" => 20}.freeze}.freeze
24
- SCHEMA = {"$schema" => "https://json-schema.org/draft/2020-12/schema", "type" => "object", "properties" => {
25
- "font_size" => {"type" => "number", "minimum" => 6, "maximum" => 96},
26
- "tab_size" => {"type" => "integer", "minimum" => 1, "maximum" => 16},
27
- "scroll_friction" => {"type" => "number", "minimum" => 0, "maximum" => 100},
28
- "soft_wrap" => {"type" => "boolean"}, "vim_mode" => {"type" => "boolean"},
29
- "use_tabs" => {"type" => "boolean"},
30
- "keymap" => {"type" => "array", "maxItems" => 128, "items" => {"type" => "object", "required" => ["bindings"], "properties" => {
31
- "context" => {"type" => "string", "maxLength" => 256},
32
- "bindings" => {"type" => "object", "maxProperties" => 1024, "additionalProperties" => {"type" => ["string", "null"]}}}}},
33
- "theme" => {"type" => "string"}, "font_family" => {"type" => ["string", "null"]},
34
- "icon_theme" => {"type" => ["string", "null"]},
35
- "tabs" => {"type" => "object"}, "terminal" => {"type" => "object"},
36
- "dock" => {"type" => "object", "properties" => {
37
- "left" => {"$ref" => "#/$defs/dock"}, "right" => {"$ref" => "#/$defs/dock"},
38
- "bottom" => {"$ref" => "#/$defs/dock"}, "panels" => {"type" => "object", "maxProperties" => 1000,
39
- "additionalProperties" => {"$ref" => "#/$defs/panel"}}}},
40
- "languages" => {"type" => "object", "additionalProperties" => {"$ref" => "#"}},
41
- "language_servers" => {"type" => "object"}}, "$defs" => {
42
- "dock" => {"type" => "object", "required" => %w[size visible], "properties" => {
43
- "size" => {"type" => "number", "exclusiveMinimum" => 0}, "visible" => {"type" => "boolean"}}},
44
- "panel" => {"type" => "object", "required" => %w[size visible], "properties" => {
45
- "size" => {"type" => "number", "exclusiveMinimum" => 0}, "visible" => {"type" => "boolean"}}}}}.freeze
11
+ ReloadFallback = Struct.new(:values, keyword_init: true)
12
+ private_constant :ReloadFallback
13
+ DEFAULT_AUTO_PAIRS = Schema::DEFAULT_AUTO_PAIRS
14
+ DEFAULTS = Schema::DEFAULTS
15
+ SCHEMA = Schema::JSON_SCHEMA
16
+ SCHEMA_MODEL = Schema::KOCHAB_SCHEMA
17
+ SCHEMA_METADATA = SCHEMA_MODEL
46
18
  attr_reader :values, :errors, :layers
47
19
  def self.schema = SCHEMA
20
+ def self.schema_model = SCHEMA_MODEL
21
+ def self.schema_metadata = SCHEMA_MODEL
22
+ def self.file_diagnostics(path)
23
+ return nil unless File.file?(path)
24
+
25
+ document = Kochab.parse(File.read(path))
26
+ diagnostics = document.errors.dup
27
+ if document.valid?
28
+ diagnostics.concat(SCHEMA_MODEL.validate(document).reject { |diagnostic| diagnostic.message == "Required value is missing" })
29
+ end
30
+ [document, diagnostics.freeze].freeze
31
+ rescue Errno::ENOENT, Errno::EACCES, Errno::EISDIR
32
+ nil
33
+ end
48
34
  def self.user_path
49
35
  File.join(ENV["XDG_CONFIG_HOME"] || File.expand_path("~/.config"), "canopus", "settings.jsonc")
50
36
  end
51
37
  def initialize(*layers)
38
+ fallback = layers.pop.values if layers.last.is_a?(ReloadFallback)
52
39
  @values, @errors = DEFAULTS.dup, []
53
40
  @layers = layers.compact
41
+ @reload_fallback = fallback
42
+ @initializing = true
54
43
  layers.compact.each { |layer| merge!(layer.is_a?(String) ? parse_file(layer) : layer) }
44
+ @initializing = false
45
+ @reload_fallback = nil
55
46
  validate!
56
47
  end
57
48
  def [](key) = @values[key.to_s]
58
49
  def paths = @layers.grep(String)
59
- def reload = self.class.new(*@layers)
50
+ def reload = self.class.new(*@layers, ReloadFallback.new(values: @values))
60
51
  def for_language(language) = Settings.new(@values, @values.fetch("languages", {}).fetch(language, {}))
61
52
  def merge!(layer)
62
- raise Error, "settings must be an object" unless layer.is_a?(Hash)
53
+ document = layer.is_a?(Kochab::Document)
54
+ raise Error, "settings must be an object" unless document || layer.is_a?(Hash)
63
55
  previous = @values
64
- @values = merge(@values, layer)
56
+ @values = document ? merge_document(@values, layer) : merge_hash(@values, layer)
65
57
  validate!
58
+ @layers << snapshot_layer(layer) unless @initializing
66
59
  self
67
60
  rescue StandardError
68
61
  @values = previous if previous
@@ -72,8 +65,22 @@ module Canopus
72
65
  source = File.file?(path) ? File.read(path) : "{}\n"
73
66
  doc = Kochab.parse(source)
74
67
  raise Error, "cannot edit invalid settings" unless doc.valid?
75
- replacement = Kochab.apply(source, doc.set([key.to_s], value))
76
- Settings.new(Kochab.parse(replacement).value)
68
+ key_path = key.is_a?(Array) ? key : [key]
69
+ raise Error, "settings key path must contain strings" unless key_path.all? { |part| part.is_a?(String) && !part.empty? }
70
+ editable_source = source
71
+ (1...key_path.length).each do |length|
72
+ current = Kochab.parse(editable_source)
73
+ parent = key_path[0...length]
74
+ next if current.range_of(parent)
75
+
76
+ editable_source = Kochab.apply(editable_source, current.set(parent, {}))
77
+ end
78
+ doc = Kochab.parse(editable_source)
79
+ replacement = Kochab.apply(editable_source, doc.set(key_path, value))
80
+ candidate = Kochab.parse(replacement)
81
+ schema_errors = SCHEMA_MODEL.validate(candidate).reject { |diagnostic| diagnostic.message == "Required value is missing" }
82
+ raise Error, "invalid setting #{key_path.join(".")}: #{schema_errors.first.message}" unless schema_errors.empty?
83
+ Settings.new(candidate)
77
84
  FileUtils.mkdir_p(File.dirname(path))
78
85
  Tempfile.create([".settings-", ".json"], File.dirname(path)) do |file|
79
86
  file.write(replacement)
@@ -90,10 +97,30 @@ module Canopus
90
97
  document = Kochab.parse(File.read(path))
91
98
  @errors.concat(document.errors)
92
99
  raise Error, "invalid settings: #{path}" unless document.valid?
93
- document.value
100
+ raise Error, "settings must be an object" unless document.value.is_a?(Hash)
101
+ document
102
+ end
103
+ def merge_hash(left, right)
104
+ left.merge(right) { |_, old, new| old.is_a?(Hash) && new.is_a?(Hash) ? merge_hash(old, new) : new }
94
105
  end
95
- def merge(left, right)
96
- left.merge(right) { |_, old, new| old.is_a?(Hash) && new.is_a?(Hash) ? merge(old, new) : new }
106
+ def merge_document(previous, document)
107
+ fallback = previous_layer(document.value, @reload_fallback || previous)
108
+ SCHEMA_MODEL.merge(previous, fallback, document)
109
+ end
110
+ def previous_layer(layer, previous)
111
+ layer.each_with_object({}) do |(key, value), result|
112
+ old = previous[key]
113
+ next unless previous.key?(key)
114
+
115
+ result[key] = value.is_a?(Hash) && old.is_a?(Hash) ? previous_layer(value, old) : snapshot_layer(old)
116
+ end
117
+ end
118
+ def snapshot_layer(value)
119
+ case value
120
+ when Hash then value.to_h { |key, child| [key, snapshot_layer(child)] }
121
+ when Array then value.map { |child| snapshot_layer(child) }
122
+ else value
123
+ end
97
124
  end
98
125
  def validate!
99
126
  {"font_size" => 6..96, "tab_size" => 1..16, "scroll_friction" => 0..100}.each do |key, range|
@@ -101,20 +128,46 @@ module Canopus
101
128
  raise Error, "invalid #{key}" unless value.is_a?(Numeric) && range.cover?(value)
102
129
  end
103
130
  raise Error, "tab_size must be an integer" unless @values["tab_size"].is_a?(Integer)
104
- %w[soft_wrap vim_mode use_tabs].each { |key| raise Error, "#{key} must be true or false" unless [true, false].include?(@values[key]) }
131
+ %w[soft_wrap vim_mode use_tabs render_ideographic_space format_on_save].each { |key| raise Error, "#{key} must be true or false" unless [true, false].include?(@values[key]) }
132
+ raise Error, "invalid render_whitespace" unless %w[none boundary selection all].include?(@values["render_whitespace"])
105
133
  validate_keymap!
134
+ validate_auto_pairs!
106
135
  %w[languages language_servers].each { |key| raise Error, "#{key} must be an object" unless @values[key].is_a?(Hash) }
107
136
  raise Error, "theme must be a string" unless @values["theme"].is_a?(String)
108
137
  raise Error, "font_family must be a string or null" unless @values["font_family"].nil? || @values["font_family"].is_a?(String)
109
138
  raise Error, "icon_theme must be a string or null" unless @values["icon_theme"].nil? || @values["icon_theme"].is_a?(String)
110
139
  validate_tabs!
111
140
  validate_terminal!
141
+ validate_diagnostics!
142
+ validate_inlay_hints!
143
+ validate_code_lens!
144
+ validate_structural_guides!
145
+ validate_sticky_scroll!
146
+ validate_breadcrumbs!
147
+ validate_minimap!
148
+ validate_auto_save!
149
+ validate_editorconfig!
150
+ validate_persistent_undo!
151
+ validate_save_actions!
152
+ validate_git!
153
+ validate_plugins!
154
+ validate_recovery!
155
+ validate_language_server_keys!
156
+ snapshot_language_servers!
157
+ validate_debug_adapters!
112
158
  validate_dock!
113
159
  @values["languages"] = @values["languages"].to_h do |name, layer|
114
160
  raise Error, "language settings must be objects" unless name.is_a?(String) && layer.is_a?(Hash)
115
161
  raise Error, "language settings cannot contain nested languages" if layer.key?("languages")
162
+ %w[debug_adapters recovery auto_save auto_save_delay persistent_undo].each do |key|
163
+ raise Error, "#{key} is a global setting" if layer.key?(key) || layer.key?(key.to_sym)
164
+ end
116
165
  checked = Settings.new(@values.merge("languages" => {}), layer)
117
- [name, layer.key?("keymap") ? layer.merge("keymap" => checked["keymap"]) : layer]
166
+ layer = layer.merge("keymap" => checked["keymap"]) if layer.key?("keymap")
167
+ layer = layer.merge("auto_pairs" => checked["auto_pairs"]) if layer.key?("auto_pairs")
168
+ layer = layer.merge("code_actions_on_save" => checked["code_actions_on_save"]) if layer.key?("code_actions_on_save")
169
+ layer = layer.merge("language_servers" => checked["language_servers"]) if layer.key?("language_servers")
170
+ [name, layer]
118
171
  end
119
172
  end
120
173
  def validate_keymap!
@@ -140,6 +193,23 @@ module Canopus
140
193
  raise Error, "invalid keymap: #{error.message}"
141
194
  end
142
195
 
196
+ def validate_auto_pairs!
197
+ pairs = @values["auto_pairs"]
198
+ raise Error, "auto_pairs must be an array of at most 64 pairs" unless pairs.is_a?(Array) && pairs.length <= 64
199
+ openers = {}
200
+ @values["auto_pairs"] = pairs.map do |pair|
201
+ raise Error, "auto_pairs entries must contain an opener and closer" unless pair.is_a?(Array) && pair.length == 2
202
+ opener, closer = pair
203
+ unless [opener, closer].all? { |value| value.is_a?(String) && value.encoding == Encoding::UTF_8 && value.valid_encoding? &&
204
+ value.each_grapheme_cluster.one? && !value.match?(/\p{Cc}/) }
205
+ raise Error, "auto_pairs openers and closers must be single non-control UTF-8 graphemes"
206
+ end
207
+ raise Error, "auto_pairs openers must be unique" if openers[opener]
208
+ openers[opener] = true
209
+ [opener.dup.freeze, closer.dup.freeze].freeze
210
+ end.freeze
211
+ end
212
+
143
213
  def validate_tabs!
144
214
  tabs = @values["tabs"]
145
215
  raise Error, "tabs must be an object" unless tabs.is_a?(Hash)
@@ -158,9 +228,10 @@ module Canopus
158
228
  (terminal["shell"].is_a?(Array) && terminal["shell"].all? { |value| value.is_a?(String) })
159
229
  raise Error, "invalid terminal.working_directory" unless terminal["working_directory"].is_a?(String)
160
230
  raise Error, "invalid terminal.env" unless terminal["env"].is_a?(Hash) && terminal["env"].all? { |key, value| key.is_a?(String) && (value.nil? || value.is_a?(String)) }
231
+ validate_terminal_profiles!(terminal)
161
232
  raise Error, "invalid terminal.font_size" unless terminal["font_size"].nil? || terminal["font_size"].is_a?(Numeric) && terminal["font_size"].between?(6, 96)
162
233
  raise Error, "invalid terminal.line_height" unless terminal["line_height"].is_a?(Numeric) && terminal["line_height"].between?(0.5, 4)
163
- %w[confirm_close_running confirm_multiline_paste copy_on_select restore_on_startup hide_when_empty].each do |key|
234
+ %w[confirm_close_running confirm_multiline_paste copy_on_select restore_on_startup hide_when_empty shell_integration].each do |key|
164
235
  raise Error, "terminal.#{key} must be true or false" unless [true, false].include?(terminal[key])
165
236
  end
166
237
  raise Error, "invalid terminal.close_on_exit" unless %w[never clean always].include?(terminal["close_on_exit"])
@@ -174,6 +245,214 @@ module Canopus
174
245
  end
175
246
  end
176
247
 
248
+ def validate_terminal_profiles!(terminal)
249
+ profiles = terminal["profiles"]
250
+ raise Error, "terminal.profiles must be an object" unless profiles.is_a?(Hash) && profiles.length <= 100
251
+ profiles.each do |name, profile|
252
+ valid_name = name.is_a?(String) && name.valid_encoding? && name.bytesize.between?(1, 128) && name == name.strip
253
+ raise Error, "invalid terminal profile name" unless valid_name
254
+ unless profile.is_a?(Hash) && profile.keys.all? { |key| %w[command path args env].include?(key) }
255
+ raise Error, "invalid terminal profile #{name}"
256
+ end
257
+ command = profile["command"]
258
+ path, args, env = profile.values_at("path", "args", "env")
259
+ valid_command = command.nil? || command.is_a?(String) && !command.empty? ||
260
+ command.is_a?(Array) && command.first.is_a?(String) && !command.first.empty? &&
261
+ command.all? { |value| value.is_a?(String) }
262
+ raise Error, "invalid terminal profile command #{name}" unless valid_command
263
+ raise Error, "terminal profile #{name} cannot use command with path or args" if command && (path || args)
264
+ raise Error, "terminal profile #{name} args require path" if args && !path
265
+ raise Error, "invalid terminal profile path #{name}" unless path.nil? || path.is_a?(String) && !path.empty?
266
+ raise Error, "invalid terminal profile args #{name}" unless args.nil? || args.is_a?(Array) && args.all? { |value| value.is_a?(String) }
267
+ valid_env = env.nil? || env.is_a?(Hash) && env.all? { |key, value| key.is_a?(String) && (value.nil? || value.is_a?(String)) }
268
+ raise Error, "invalid terminal profile env #{name}" unless valid_env
269
+ end
270
+ default = terminal["default_profile"]
271
+ unless default.nil? || default.is_a?(String) && profiles.key?(default)
272
+ raise Error, "terminal.default_profile must name a configured profile"
273
+ end
274
+ end
275
+
276
+ def validate_git!
277
+ git = @values["git"]
278
+ raise Error, "git must be an object" unless git.is_a?(Hash)
279
+ raise Error, "invalid git.inline_blame" unless %w[off cursor all].include?(git["inline_blame"])
280
+ raise Error, "git.autofetch must be true or false" unless [true, false].include?(git["autofetch"])
281
+ interval = git["autofetch_interval"]
282
+ raise Error, "invalid git.autofetch_interval" unless interval.is_a?(Integer) && interval.between?(10, 86_400)
283
+ end
284
+
285
+ def validate_recovery!
286
+ recovery = @values["recovery"]
287
+ raise Error, "recovery must be an object" unless recovery.is_a?(Hash)
288
+ raise Error, "recovery.enabled must be true or false" unless [true, false].include?(recovery["enabled"])
289
+ interval = recovery["interval"]
290
+ raise Error, "invalid recovery.interval" unless interval.is_a?(Integer) && interval.between?(100, 3_600_000)
291
+ end
292
+
293
+ def validate_plugins!
294
+ plugins = @values["plugins"]
295
+ raise Error, "plugins must be an object" unless plugins.is_a?(Hash)
296
+ raise Error, "invalid plugins.sandbox" unless %w[off auto required].include?(plugins["sandbox"])
297
+ end
298
+
299
+ def validate_diagnostics!
300
+ diagnostics = @values["diagnostics"]
301
+ raise Error, "diagnostics must be an object" unless diagnostics.is_a?(Hash)
302
+ raise Error, "diagnostics.inline must be true or false" unless [true, false].include?(diagnostics["inline"])
303
+ length = diagnostics["inline_max_length"]
304
+ raise Error, "invalid diagnostics.inline_max_length" unless length.is_a?(Integer) && length.between?(1, 10_000)
305
+ unless %w[error warning information hint].include?(diagnostics["severity"])
306
+ raise Error, "invalid diagnostics.severity"
307
+ end
308
+ end
309
+
310
+ def validate_inlay_hints!
311
+ hints = @values["inlay_hints"]
312
+ raise Error, "inlay_hints must be an object" unless hints.is_a?(Hash)
313
+ %w[enabled parameter_names types].each do |key|
314
+ raise Error, "inlay_hints.#{key} must be true or false" unless [true, false].include?(hints[key])
315
+ end
316
+ length = hints["max_length"]
317
+ raise Error, "invalid inlay_hints.max_length" unless length.is_a?(Integer) && length.between?(1, 10_000)
318
+ end
319
+
320
+ def validate_code_lens!
321
+ lens = @values["code_lens"]
322
+ raise Error, "code_lens must be an object" unless lens.is_a?(Hash)
323
+ raise Error, "code_lens.enabled must be true or false" unless [true, false].include?(lens["enabled"])
324
+ end
325
+
326
+ def validate_structural_guides!
327
+ unless [true, false].include?(@values["bracket_colorization"])
328
+ raise Error, "bracket_colorization must be true or false"
329
+ end
330
+ guides = @values["indent_guides"]
331
+ raise Error, "indent_guides must be an object" unless guides.is_a?(Hash)
332
+ %w[enabled active].each do |key|
333
+ raise Error, "indent_guides.#{key} must be true or false" unless [true, false].include?(guides[key])
334
+ end
335
+ end
336
+
337
+ def validate_sticky_scroll!
338
+ sticky = @values["sticky_scroll"]
339
+ raise Error, "sticky_scroll must be an object" unless sticky.is_a?(Hash)
340
+ raise Error, "sticky_scroll.enabled must be true or false" unless [true, false].include?(sticky["enabled"])
341
+ unless sticky["max_lines"].is_a?(Integer) && sticky["max_lines"].between?(1, 20)
342
+ raise Error, "invalid sticky_scroll.max_lines"
343
+ end
344
+ end
345
+
346
+ def validate_breadcrumbs!
347
+ breadcrumbs = @values["breadcrumbs"]
348
+ unless breadcrumbs.is_a?(Hash) && [true, false].include?(breadcrumbs["enabled"])
349
+ raise Error, "breadcrumbs.enabled must be true or false"
350
+ end
351
+ end
352
+
353
+ def validate_minimap!
354
+ minimap = @values["minimap"]
355
+ raise Error, "minimap must be an object" unless minimap.is_a?(Hash)
356
+ %w[enabled show_diagnostics].each do |key|
357
+ raise Error, "minimap.#{key} must be true or false" unless [true, false].include?(minimap[key])
358
+ end
359
+ raise Error, "invalid minimap.width" unless minimap["width"].is_a?(Integer) && minimap["width"].between?(40, 400)
360
+ end
361
+
362
+ def validate_auto_save!
363
+ raise Error, "invalid auto_save" unless %w[off after_delay on_focus_change].include?(@values["auto_save"])
364
+ delay = @values["auto_save_delay"]
365
+ raise Error, "invalid auto_save_delay" unless delay.is_a?(Integer) && delay.between?(100, 3_600_000)
366
+ end
367
+
368
+ def validate_editorconfig!
369
+ raise Error, "editorconfig must be true or false" unless [true, false].include?(@values["editorconfig"])
370
+ %w[trim_trailing_whitespace insert_final_newline].each do |key|
371
+ value = @values[key]
372
+ raise Error, "#{key} must be true, false, or null" unless value.nil? || [true, false].include?(value)
373
+ end
374
+ length = @values["max_line_length"]
375
+ raise Error, "invalid max_line_length" unless length.nil? || length.is_a?(Integer) && length.between?(1, 1_000_000)
376
+ end
377
+
378
+ def validate_persistent_undo!
379
+ persistent = @values["persistent_undo"]
380
+ raise Error, "persistent_undo must be an object" unless persistent.is_a?(Hash)
381
+ raise Error, "persistent_undo.enabled must be true or false" unless [true, false].include?(persistent["enabled"])
382
+ max_entries = persistent["max_entries"]
383
+ raise Error, "invalid persistent_undo.max_entries" unless max_entries.is_a?(Integer) && max_entries.between?(1, 10_000)
384
+ expire_days = persistent["expire_days"]
385
+ raise Error, "invalid persistent_undo.expire_days" unless expire_days.is_a?(Integer) && expire_days.between?(1, 3_650)
386
+ end
387
+
388
+ def validate_save_actions!
389
+ actions = @values["code_actions_on_save"]
390
+ valid = actions.is_a?(Array) && actions.length <= 64 && actions.uniq.length == actions.length && actions.all? do |action|
391
+ action.is_a?(String) && action.valid_encoding? && action.bytesize.between?(1, 256) &&
392
+ action == action.strip && !action.match?(/[\x00-\x1f\x7f]/)
393
+ end
394
+ raise Error, "invalid code_actions_on_save" unless valid
395
+ @values["code_actions_on_save"] = actions.map { |action| action.dup.freeze }.freeze
396
+ timeout = @values["format_on_save_timeout"]
397
+ raise Error, "invalid format_on_save_timeout" unless timeout.is_a?(Integer) && timeout.between?(1, 60_000)
398
+ end
399
+
400
+ def snapshot_language_servers!
401
+ snapshot = JSON.parse(JSON.generate(@values["language_servers"]))
402
+ freeze_value = lambda do |value|
403
+ value.each { |key, child| key.freeze; freeze_value.call(child) } if value.is_a?(Hash)
404
+ value.each { |child| freeze_value.call(child) } if value.is_a?(Array)
405
+ value.freeze
406
+ end
407
+ @values["language_servers"] = freeze_value.call(snapshot)
408
+ rescue JSON::GeneratorError, JSON::ParserError, JSON::NestingError
409
+ raise Error, "invalid language_servers"
410
+ end
411
+
412
+ def validate_language_server_keys!
413
+ @values["language_servers"].each do |name, value|
414
+ raise Error, "language server names must be strings" unless name.is_a?(String)
415
+ options = value.is_a?(Array) ? value.select { |item| item.is_a?(Hash) } : value.is_a?(Hash) ? [value] : []
416
+ options.each do |option|
417
+ raise Error, "language server option keys must be strings" unless option.keys.all? { |key| key.is_a?(String) }
418
+ env = option["env"]
419
+ raise Error, "language server env keys must be strings" if env.is_a?(Hash) && !env.keys.all? { |key| key.is_a?(String) }
420
+ end
421
+ end
422
+ end
423
+
424
+ def validate_debug_adapters!
425
+ adapters = @values["debug_adapters"]
426
+ raise Error, "debug_adapters must be an object of at most 64 adapters" unless adapters.is_a?(Hash) && adapters.length <= 64
427
+ adapters.each do |type, options|
428
+ valid_type = type.is_a?(String) && type.valid_encoding? && type.bytesize.between?(1, 128) &&
429
+ type.match?(/\A[A-Za-z0-9_.-]+\z/)
430
+ raise Error, "invalid debug adapter type" unless valid_type
431
+ unless options.is_a?(Hash) && options.keys.all? { |key| key.is_a?(String) } &&
432
+ (options.keys - %w[command transport]).empty?
433
+ raise Error, "invalid debug adapter options for #{type}"
434
+ end
435
+ command = options["command"]
436
+ valid_command = command.is_a?(Array) && command.length.between?(1, 32) && command.all? do |argument|
437
+ argument.is_a?(String) && argument.valid_encoding? && argument.bytesize.between?(1, 4096) &&
438
+ !argument.match?(/[\x00-\x1f\x7f]/)
439
+ end
440
+ raise Error, "invalid debug adapter command for #{type}" unless valid_command
441
+ raise Error, "invalid debug adapter transport for #{type}" unless %w[stdio tcp].include?(options["transport"])
442
+ end
443
+ serialized = JSON.generate(adapters)
444
+ raise Error, "debug_adapters exceeds 1 MiB" if serialized.bytesize > 1_048_576
445
+ snapshot = JSON.parse(serialized)
446
+ freeze_value = lambda do |value|
447
+ value.each { |key, child| key.freeze; freeze_value.call(child) } if value.is_a?(Hash)
448
+ value.each { |child| freeze_value.call(child) } if value.is_a?(Array)
449
+ value.freeze
450
+ end
451
+ @values["debug_adapters"] = freeze_value.call(snapshot)
452
+ rescue JSON::GeneratorError, JSON::ParserError, JSON::NestingError
453
+ raise Error, "invalid debug_adapters"
454
+ end
455
+
177
456
  def validate_dock!
178
457
  dock = @values["dock"]
179
458
  raise Error, "dock must be an object" unless dock.is_a?(Hash)