theme-check 1.7.2 → 1.9.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (84) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/CHANGELOG.md +47 -0
  4. data/README.md +10 -0
  5. data/RELEASING.md +13 -0
  6. data/config/default.yml +5 -0
  7. data/data/shopify_liquid/deprecated_filters.yml +4 -0
  8. data/data/shopify_liquid/filters.yml +3 -1
  9. data/docs/checks/TEMPLATE.md.erb +24 -19
  10. data/docs/checks/schema_json_format.md +76 -0
  11. data/docs/language_server/code-action-command-palette.png +0 -0
  12. data/docs/language_server/code-action-flow.png +0 -0
  13. data/docs/language_server/code-action-keyboard.png +0 -0
  14. data/docs/language_server/code-action-light-bulb.png +0 -0
  15. data/docs/language_server/code-action-problem.png +0 -0
  16. data/docs/language_server/code-action-quickfix.png +0 -0
  17. data/docs/language_server/how_to_correct_code_with_code_actions_and_execute_command.md +197 -0
  18. data/exe/theme-check-language-server +0 -4
  19. data/lib/theme_check/checks/asset_size_app_block_css.rb +2 -3
  20. data/lib/theme_check/checks/asset_size_app_block_javascript.rb +2 -3
  21. data/lib/theme_check/checks/asset_url_filters.rb +2 -0
  22. data/lib/theme_check/checks/default_locale.rb +1 -1
  23. data/lib/theme_check/checks/deprecated_filter.rb +81 -4
  24. data/lib/theme_check/checks/deprecated_global_app_block_type.rb +2 -3
  25. data/lib/theme_check/checks/matching_schema_translations.rb +14 -9
  26. data/lib/theme_check/checks/matching_translations.rb +1 -0
  27. data/lib/theme_check/checks/missing_required_template_files.rb +3 -3
  28. data/lib/theme_check/checks/missing_template.rb +1 -1
  29. data/lib/theme_check/checks/pagination_size.rb +2 -3
  30. data/lib/theme_check/checks/remote_asset.rb +5 -0
  31. data/lib/theme_check/checks/required_directories.rb +1 -1
  32. data/lib/theme_check/checks/required_layout_theme_object.rb +9 -4
  33. data/lib/theme_check/checks/schema_json_format.rb +29 -0
  34. data/lib/theme_check/checks/space_inside_braces.rb +132 -87
  35. data/lib/theme_check/checks/translation_key_exists.rb +33 -13
  36. data/lib/theme_check/checks/unused_assign.rb +3 -2
  37. data/lib/theme_check/checks/unused_snippet.rb +1 -1
  38. data/lib/theme_check/checks/valid_html_translation.rb +1 -1
  39. data/lib/theme_check/checks/valid_schema.rb +2 -2
  40. data/lib/theme_check/corrector.rb +34 -23
  41. data/lib/theme_check/file_system_storage.rb +4 -3
  42. data/lib/theme_check/html_node.rb +122 -6
  43. data/lib/theme_check/html_visitor.rb +1 -32
  44. data/lib/theme_check/in_memory_storage.rb +9 -0
  45. data/lib/theme_check/json_helpers.rb +14 -0
  46. data/lib/theme_check/language_server/bridge.rb +19 -5
  47. data/lib/theme_check/language_server/client_capabilities.rb +27 -0
  48. data/lib/theme_check/language_server/code_action_engine.rb +32 -0
  49. data/lib/theme_check/language_server/code_action_provider.rb +42 -0
  50. data/lib/theme_check/language_server/code_action_providers/quickfix_code_action_provider.rb +83 -0
  51. data/lib/theme_check/language_server/code_action_providers/source_fix_all_code_action_provider.rb +40 -0
  52. data/lib/theme_check/language_server/configuration.rb +69 -0
  53. data/lib/theme_check/language_server/diagnostic.rb +124 -0
  54. data/lib/theme_check/language_server/diagnostics_engine.rb +15 -60
  55. data/lib/theme_check/language_server/diagnostics_manager.rb +136 -0
  56. data/lib/theme_check/language_server/document_change_corrector.rb +267 -0
  57. data/lib/theme_check/language_server/document_link_provider.rb +6 -6
  58. data/lib/theme_check/language_server/execute_command_engine.rb +19 -0
  59. data/lib/theme_check/language_server/execute_command_provider.rb +30 -0
  60. data/lib/theme_check/language_server/execute_command_providers/correction_execute_command_provider.rb +48 -0
  61. data/lib/theme_check/language_server/execute_command_providers/run_checks_execute_command_provider.rb +22 -0
  62. data/lib/theme_check/language_server/handler.rb +83 -29
  63. data/lib/theme_check/language_server/io_messenger.rb +11 -1
  64. data/lib/theme_check/language_server/protocol.rb +4 -0
  65. data/lib/theme_check/language_server/server.rb +29 -11
  66. data/lib/theme_check/language_server/uri_helper.rb +1 -0
  67. data/lib/theme_check/language_server/versioned_in_memory_storage.rb +69 -0
  68. data/lib/theme_check/language_server.rb +23 -5
  69. data/lib/theme_check/liquid_node.rb +255 -12
  70. data/lib/theme_check/locale_diff.rb +39 -8
  71. data/lib/theme_check/node.rb +16 -0
  72. data/lib/theme_check/offense.rb +27 -23
  73. data/lib/theme_check/position.rb +4 -4
  74. data/lib/theme_check/regex_helpers.rb +1 -1
  75. data/lib/theme_check/schema_helper.rb +70 -0
  76. data/lib/theme_check/storage.rb +4 -0
  77. data/lib/theme_check/tags.rb +0 -1
  78. data/lib/theme_check/theme.rb +1 -1
  79. data/lib/theme_check/theme_file.rb +8 -1
  80. data/lib/theme_check/theme_file_rewriter.rb +28 -6
  81. data/lib/theme_check/version.rb +1 -1
  82. data/lib/theme_check.rb +11 -2
  83. metadata +26 -3
  84. data/lib/theme_check/language_server/diagnostics_tracker.rb +0 -66
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class Configuration
6
+ CHECK_ON_OPEN = :"themeCheck.checkOnOpen"
7
+ CHECK_ON_SAVE = :"themeCheck.checkOnSave"
8
+ CHECK_ON_CHANGE = :"themeCheck.checkOnChange"
9
+
10
+ def initialize(bridge, capabilities)
11
+ @bridge = bridge
12
+ @capabilities = capabilities
13
+ @mutex = Mutex.new
14
+ @initialized = false
15
+ @config = {
16
+ CHECK_ON_OPEN => @capabilities.initialization_option(CHECK_ON_OPEN) || true,
17
+ CHECK_ON_SAVE => @capabilities.initialization_option(CHECK_ON_SAVE) || true,
18
+ CHECK_ON_CHANGE => @capabilities.initialization_option(CHECK_ON_CHANGE) || true,
19
+ }
20
+ end
21
+
22
+ def fetch(force: nil)
23
+ @mutex.synchronize do
24
+ return unless @capabilities.supports_workspace_configuration?
25
+ return if initialized? && !force
26
+ check_on_open, check_on_save, check_on_change = @bridge.send_request(
27
+ "workspace/configuration",
28
+ items: [
29
+ { section: CHECK_ON_OPEN },
30
+ { section: CHECK_ON_SAVE },
31
+ { section: CHECK_ON_CHANGE },
32
+ ],
33
+ )
34
+ @config[CHECK_ON_OPEN] = check_on_open unless check_on_open.nil?
35
+ @config[CHECK_ON_CHANGE] = check_on_change unless check_on_change.nil?
36
+ @config[CHECK_ON_SAVE] = check_on_save unless check_on_save.nil?
37
+ @initialized = true
38
+ end
39
+ end
40
+
41
+ def register_did_change_capability
42
+ return unless @capabilities.supports_workspace_did_change_configuration_dynamic_registration?
43
+ @bridge.send_request('client/registerCapability', registrations: [{
44
+ id: "workspace/didChangeConfiguration",
45
+ method: "workspace/didChangeConfiguration",
46
+ }])
47
+ end
48
+
49
+ def initialized?
50
+ @initialized
51
+ end
52
+
53
+ def check_on_open?
54
+ fetch # making sure we have an initialized value
55
+ @config[CHECK_ON_OPEN]
56
+ end
57
+
58
+ def check_on_save?
59
+ fetch # making sure we have for an initialized value
60
+ @config[CHECK_ON_SAVE]
61
+ end
62
+
63
+ def check_on_change?
64
+ fetch # making sure we have for an initialized value
65
+ @config[CHECK_ON_CHANGE]
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class Diagnostic
6
+ include URIHelper
7
+
8
+ attr_reader :offense
9
+
10
+ def initialize(offense)
11
+ @offense = offense
12
+ @diagnostic = nil
13
+ end
14
+
15
+ def ==(other)
16
+ case other
17
+ when Hash, Diagnostic
18
+ to_h == other.to_h
19
+ else
20
+ raise ArgumentError
21
+ end
22
+ end
23
+
24
+ def to_h
25
+ return @diagnostic unless @diagnostic.nil?
26
+ @diagnostic = {
27
+ source: "theme-check",
28
+ code: code,
29
+ message: message,
30
+ range: range,
31
+ severity: severity,
32
+ data: data,
33
+ }
34
+ @diagnostic[:codeDescription] = code_description unless offense.doc.nil?
35
+ @diagnostic
36
+ end
37
+
38
+ def to_s
39
+ to_h.to_s
40
+ end
41
+
42
+ def single_file?
43
+ offense.single_file?
44
+ end
45
+
46
+ def correctable?
47
+ offense.correctable?
48
+ end
49
+
50
+ def code
51
+ offense.code_name
52
+ end
53
+
54
+ def message
55
+ offense.message
56
+ end
57
+
58
+ def code_description
59
+ {
60
+ href: offense.doc,
61
+ }
62
+ end
63
+
64
+ def severity
65
+ case offense.severity
66
+ when :error
67
+ 1
68
+ when :suggestion
69
+ 2
70
+ when :style
71
+ 3
72
+ else
73
+ 4
74
+ end
75
+ end
76
+
77
+ def range
78
+ {
79
+ start: {
80
+ line: offense.start_row,
81
+ character: offense.start_column,
82
+ },
83
+ end: {
84
+ line: offense.end_row,
85
+ character: offense.end_column,
86
+ },
87
+ }
88
+ end
89
+
90
+ def start_index
91
+ offense.start_index
92
+ end
93
+
94
+ def end_index
95
+ offense.end_index
96
+ end
97
+
98
+ def absolute_path
99
+ @absolute_path ||= offense&.theme_file&.path
100
+ end
101
+
102
+ def relative_path
103
+ @relative_path ||= offense&.theme_file&.relative_path
104
+ end
105
+
106
+ def uri
107
+ @uri ||= absolute_path && file_uri(absolute_path)
108
+ end
109
+
110
+ def file_version
111
+ @version ||= offense&.version
112
+ end
113
+
114
+ def data
115
+ {
116
+ absolute_path: absolute_path.to_s,
117
+ relative_path: relative_path.to_s,
118
+ uri: uri,
119
+ version: file_version,
120
+ }
121
+ end
122
+ end
123
+ end
124
+ end
@@ -5,31 +5,30 @@ module ThemeCheck
5
5
  class DiagnosticsEngine
6
6
  include URIHelper
7
7
 
8
- def initialize(bridge)
8
+ attr_reader :storage
9
+
10
+ def initialize(storage, bridge, diagnostics_manager = DiagnosticsManager.new)
9
11
  @diagnostics_lock = Mutex.new
10
- @diagnostics_tracker = DiagnosticsTracker.new
12
+ @diagnostics_manager = diagnostics_manager
13
+ @storage = storage
11
14
  @bridge = bridge
12
15
  @token = 0
13
16
  end
14
17
 
15
18
  def first_run?
16
- @diagnostics_tracker.first_run?
19
+ @diagnostics_manager.first_run?
17
20
  end
18
21
 
19
- def analyze_and_send_offenses(absolute_path, config)
22
+ def analyze_and_send_offenses(absolute_path, config, force: false)
20
23
  return unless @diagnostics_lock.try_lock
21
24
  @token += 1
22
25
  @bridge.send_create_work_done_progress_request(@token)
23
- storage = ThemeCheck::FileSystemStorage.new(
24
- config.root,
25
- ignored_patterns: config.ignored_patterns
26
- )
27
26
  theme = ThemeCheck::Theme.new(storage)
28
27
  analyzer = ThemeCheck::Analyzer.new(theme, config.enabled_checks)
29
28
 
30
- if @diagnostics_tracker.first_run?
29
+ if @diagnostics_manager.first_run? || force
31
30
  @bridge.send_work_done_progress_begin(@token, "Full theme check")
32
- @bridge.log("Checking #{config.root}")
31
+ @bridge.log("Checking #{storage.root}")
33
32
  offenses = nil
34
33
  time = Benchmark.measure do
35
34
  offenses = analyzer.analyze_theme do |path, i, total|
@@ -55,7 +54,7 @@ module ThemeCheck
55
54
  end_message = "Found #{offenses.size} new offenses in #{format("%0.2f", time.real)}s"
56
55
  @bridge.send_work_done_progress_end(@token, end_message)
57
56
  @bridge.log(end_message)
58
- send_diagnostics(offenses, [absolute_path])
57
+ send_diagnostics(offenses, [relative_path])
59
58
  end
60
59
  end
61
60
  @diagnostics_lock.unlock
@@ -64,62 +63,18 @@ module ThemeCheck
64
63
  private
65
64
 
66
65
  def send_diagnostics(offenses, analyzed_files = nil)
67
- @diagnostics_tracker.build_diagnostics(offenses, analyzed_files: analyzed_files) do |path, diagnostic_offenses|
68
- send_diagnostic(path, diagnostic_offenses)
66
+ @diagnostics_manager.build_diagnostics(offenses, analyzed_files: analyzed_files).each do |relative_path, diagnostics|
67
+ send_diagnostic(relative_path, diagnostics)
69
68
  end
70
69
  end
71
70
 
72
- def send_diagnostic(path, offenses)
71
+ def send_diagnostic(relative_path, diagnostics)
73
72
  # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#notificationMessage
74
73
  @bridge.send_notification('textDocument/publishDiagnostics', {
75
- uri: file_uri(path),
76
- diagnostics: offenses.map { |offense| offense_to_diagnostic(offense) },
74
+ uri: file_uri(storage.path(relative_path)),
75
+ diagnostics: diagnostics.map(&:to_h),
77
76
  })
78
77
  end
79
-
80
- def offense_to_diagnostic(offense)
81
- diagnostic = {
82
- code: offense.code_name,
83
- message: offense.message,
84
- range: range(offense),
85
- severity: severity(offense),
86
- source: "theme-check",
87
- }
88
- diagnostic["codeDescription"] = code_description(offense) unless offense.doc.nil?
89
- diagnostic
90
- end
91
-
92
- def code_description(offense)
93
- {
94
- href: offense.doc,
95
- }
96
- end
97
-
98
- def severity(offense)
99
- case offense.severity
100
- when :error
101
- 1
102
- when :suggestion
103
- 2
104
- when :style
105
- 3
106
- else
107
- 4
108
- end
109
- end
110
-
111
- def range(offense)
112
- {
113
- start: {
114
- line: offense.start_line,
115
- character: offense.start_column,
116
- },
117
- end: {
118
- line: offense.end_line,
119
- character: offense.end_column,
120
- },
121
- }
122
- end
123
78
  end
124
79
  end
125
80
  end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+ require "logger"
3
+
4
+ module ThemeCheck
5
+ module LanguageServer
6
+ class DiagnosticsManager
7
+ # This class exists to facilitate LanguageServer diagnostics tracking.
8
+ #
9
+ # Motivations:
10
+ # 1. The first time we lint, we want all the errors from all the files.
11
+ # 2. If we fix all the errors in a file, we have to send an empty array for that file.
12
+ # 3. If we do a partial check, we should consider the whole theme diagnostics as valid, and return cached results
13
+ # 4. We should be able to create WorkspaceEdits from diagnostics, so that the ExecuteCommandEngine can do its job
14
+ # 5. We should clean up diagnostics that were applied by the client
15
+ def initialize
16
+ @latest_diagnostics = {} # { [Pathname(relative_path)] => Diagnostic[] }
17
+ @mutex = Mutex.new
18
+ @first_run = true
19
+ end
20
+
21
+ def first_run?
22
+ @first_run
23
+ end
24
+
25
+ def diagnostics(relative_path)
26
+ relative_path = Pathname.new(relative_path) if relative_path.is_a?(String)
27
+ @mutex.synchronize { @latest_diagnostics[relative_path] || [] }
28
+ end
29
+
30
+ def build_diagnostics(offenses, analyzed_files: nil)
31
+ @mutex.synchronize do
32
+ full_check = analyzed_files.nil?
33
+ analyzed_paths = analyzed_files.map { |path| Pathname.new(path) } unless full_check
34
+
35
+ # When analyzed_files is nil, contains all offenses.
36
+ # When analyzed_files is !nil, contains all whole theme offenses and single file offenses of the analyzed_files.
37
+ current_diagnostics = offenses
38
+ .select(&:theme_file)
39
+ .group_by(&:theme_file)
40
+ .transform_keys { |theme_file| Pathname.new(theme_file.relative_path) }
41
+ .transform_values do |theme_file_offenses|
42
+ theme_file_offenses.map { |o| Diagnostic.new(o) }
43
+ end
44
+
45
+ previous_paths = paths(@latest_diagnostics)
46
+ current_paths = paths(current_diagnostics)
47
+
48
+ diagnostics_update = (current_paths + previous_paths).map do |path|
49
+ # When doing a full_check, we either send the current
50
+ # diagnostics or an empty array to clear the diagnostics
51
+ # for that file.
52
+ if full_check
53
+ [path, current_diagnostics[path] || []]
54
+
55
+ # When doing a partial check, the single file diagnostics
56
+ # from the previous runs should be sent. Otherwise the
57
+ # latest results are the good ones.
58
+ else
59
+ new_diagnostics = current_diagnostics[path] || []
60
+ should_use_cached_results = !analyzed_paths.include?(path)
61
+ old_diagnostics = should_use_cached_results ? single_file_diagnostics(path) : []
62
+ [path, new_diagnostics + old_diagnostics]
63
+ end
64
+ end.to_h
65
+
66
+ @latest_diagnostics = diagnostics_update.reject { |_, v| v.empty? }
67
+ @first_run = false
68
+ diagnostics_update
69
+ end
70
+ end
71
+
72
+ def workspace_edit(diagnostics)
73
+ diagnostics = sanitize(diagnostics)
74
+ .select(&:correctable?)
75
+
76
+ {
77
+ documentChanges: document_changes(diagnostics),
78
+ }
79
+ end
80
+
81
+ def delete_applied(diagnostics)
82
+ diagnostics = sanitize(diagnostics)
83
+ .select(&:correctable?)
84
+
85
+ previous_paths = paths(@latest_diagnostics)
86
+
87
+ diagnostics.each do |diagnostic|
88
+ delete(diagnostic.relative_path, diagnostic)
89
+ end
90
+
91
+ current_paths = paths(@latest_diagnostics)
92
+
93
+ (current_paths + previous_paths).map do |path|
94
+ [path, @latest_diagnostics[path] || []]
95
+ end.to_h
96
+ end
97
+
98
+ private
99
+
100
+ def sanitize(diagnostics)
101
+ diagnostics = diagnostics.map { |hash| find(hash) }.reject(&:nil?) if diagnostics[0]&.is_a?(Hash)
102
+ diagnostics
103
+ end
104
+
105
+ def delete(relative_path, diagnostic)
106
+ relative_path = Pathname.new(relative_path) if relative_path.is_a?(String)
107
+ @mutex.synchronize do
108
+ @latest_diagnostics[relative_path]&.delete(diagnostic)
109
+ @latest_diagnostics.delete(relative_path) if @latest_diagnostics[relative_path]&.empty?
110
+ end
111
+ end
112
+
113
+ def find(diagnostic_hash)
114
+ diagnostics(diagnostic_hash.dig(:data, :relative_path))
115
+ .find { |d| d == diagnostic_hash }
116
+ end
117
+
118
+ def document_changes(diagnostics)
119
+ corrector = DocumentChangeCorrector.new
120
+ diagnostics.each do |diagnostic|
121
+ offense = diagnostic.offense
122
+ offense.correct(corrector)
123
+ end
124
+ corrector.document_changes
125
+ end
126
+
127
+ def paths(diagnostics)
128
+ (diagnostics || {}).keys.map { |path| Pathname.new(path) }.to_set
129
+ end
130
+
131
+ def single_file_diagnostics(relative_path)
132
+ @latest_diagnostics[relative_path]&.select(&:single_file?) || []
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ module LanguageServer
5
+ class DocumentChangeCorrector
6
+ include URIHelper
7
+ include JsonHelpers
8
+
9
+ def initialize
10
+ @json_edits = {}
11
+ @json_file_edits = {}
12
+ @text_document_edits = {}
13
+ @create_files = []
14
+ @rename_files = []
15
+ @delete_files = []
16
+ end
17
+
18
+ def document_changes
19
+ apply_json_edits
20
+ apply_json_file_edits
21
+ @create_files + @rename_files + @text_document_edits.values + @delete_files
22
+ end
23
+
24
+ # @param node [Node]
25
+ def insert_before(node, content, character_range = nil)
26
+ position = character_range_position(node, character_range) if character_range
27
+ edits(node) << {
28
+ range: {
29
+ start: start_location(position || node),
30
+ end: start_location(position || node),
31
+ },
32
+ newText: content,
33
+ }
34
+ end
35
+
36
+ # @param node [Node]
37
+ def insert_after(node, content, character_range = nil)
38
+ position = character_range_position(node, character_range) if character_range
39
+ edits(node) << {
40
+ range: {
41
+ start: end_location(position || node),
42
+ end: end_location(position || node),
43
+ },
44
+ newText: content,
45
+ }
46
+ end
47
+
48
+ def replace(node, content, character_range = nil)
49
+ position = character_range_position(node, character_range) if character_range
50
+ edits(node) << {
51
+ range: range(position || node),
52
+ newText: content,
53
+ }
54
+ end
55
+
56
+ # @param node [LiquidNode]
57
+ def remove(node)
58
+ edits(node) << {
59
+ range: {
60
+ start: { line: node.outer_markup_start_row, character: node.outer_markup_start_column },
61
+ end: { line: node.outer_markup_end_row, character: node.outer_markup_end_column },
62
+ },
63
+ newText: '',
64
+ }
65
+ end
66
+
67
+ def replace_inner_markup(node, content)
68
+ edits(node) << {
69
+ range: {
70
+ start: {
71
+ line: node.inner_markup_start_row,
72
+ character: node.inner_markup_start_column,
73
+ },
74
+ end: {
75
+ line: node.inner_markup_end_row,
76
+ character: node.inner_markup_end_column,
77
+ },
78
+ },
79
+ newText: content,
80
+ }
81
+ end
82
+
83
+ def replace_inner_json(node, json, **pretty_json_opts)
84
+ # Kind of brittle alert: We're assuming that modifications are
85
+ # made directly on the same json hash (e.g. schema). As such,
86
+ # if this assumption is true, then it follows that the
87
+ # "correct" JSON is the _last_ one that we defined.
88
+ #
89
+ # We're going to append those changes to the text edit when
90
+ # we're done.
91
+ #
92
+ # We're doing this because no language client will accept
93
+ # text modifications that occur on the same range. So we need
94
+ # to dedup our JSON edits for the client to accept our change.
95
+ #
96
+ # What we're doing here is overwriting the json edit for a
97
+ # node to the latest one that is called. If all the edits
98
+ # occur on the same hash, this final hash will have all the
99
+ # edits in it.
100
+ @json_edits[node] = [json, pretty_json_opts]
101
+ end
102
+
103
+ def wrap(node, insert_before, insert_after)
104
+ edits(node) << {
105
+ range: range(node),
106
+ newText: insert_before + node.markup + insert_after,
107
+ }
108
+ end
109
+
110
+ def create_file(storage, relative_path, contents = nil, overwrite: false)
111
+ uri = file_uri(storage.path(relative_path))
112
+ @create_files << create_file_change(uri, overwrite)
113
+ return if contents.nil?
114
+ text_document = { uri: uri, version: nil }
115
+ @text_document_edits[text_document] = {
116
+ textDocument: text_document,
117
+ edits: [{
118
+ range: {
119
+ start: { line: 0, character: 0 },
120
+ end: { line: 0, character: 0 },
121
+ },
122
+ newText: contents,
123
+ }],
124
+ }
125
+ end
126
+
127
+ def remove_file(storage, relative_path)
128
+ uri = file_uri(storage.path(relative_path))
129
+ @delete_files << delete_file_change(uri)
130
+ end
131
+
132
+ def mkdir(storage, relative_path)
133
+ path = Pathname.new(relative_path).join("tmp").to_s
134
+ # The LSP doesn't have a concept for directories, so what we
135
+ # do is create a file and then delete it.
136
+ #
137
+ # It does the job :upside_down_smile:.
138
+ create_file(storage, path)
139
+ remove_file(storage, path)
140
+ end
141
+
142
+ def add_translation(file, path, value)
143
+ raise ArgumentError unless file.is_a?(JsonFile)
144
+ hash = file.content
145
+ SchemaHelper.set(hash, path, value)
146
+ @json_file_edits[file] = hash
147
+ end
148
+
149
+ def remove_translation(file, path)
150
+ raise ArgumentError unless file.is_a?(JsonFile)
151
+ hash = file.content
152
+ SchemaHelper.delete(hash, path)
153
+ @json_file_edits[file] = hash
154
+ end
155
+
156
+ private
157
+
158
+ def apply_json_edits
159
+ @json_edits.each do |node, (json, pretty_json_opts)|
160
+ replace_inner_markup(node, pretty_json(json, **pretty_json_opts))
161
+ end
162
+ end
163
+
164
+ def apply_json_file_edits
165
+ @json_file_edits.each do |file, hash|
166
+ replace_entire_file(file, JSON.pretty_generate(hash))
167
+ end
168
+ end
169
+
170
+ def replace_entire_file(file, contents)
171
+ text_document = to_text_document(file)
172
+ position = ThemeCheck::StrictPosition.new(file.source, file.source, 0)
173
+ @text_document_edits[text_document] = {
174
+ textDocument: text_document,
175
+ edits: [{
176
+ range: {
177
+ start: { line: 0, character: 0 },
178
+ end: { line: position.end_row, character: position.end_column },
179
+ },
180
+ newText: contents,
181
+ }],
182
+ }
183
+ end
184
+
185
+ # @param node [Node]
186
+ def text_document_edit(node)
187
+ text_document = to_text_document(node)
188
+ @text_document_edits[text_document] ||= {
189
+ textDocument: text_document,
190
+ edits: [],
191
+ }
192
+ end
193
+
194
+ def create_file_change(uri, overwrite = false)
195
+ change = {}
196
+ change[:kind] = 'create'
197
+ change[:uri] = uri
198
+ change[:options] = { overwrite: overwrite } if overwrite
199
+ change
200
+ end
201
+
202
+ def delete_file_change(uri)
203
+ {
204
+ kind: 'delete',
205
+ uri: uri,
206
+ }
207
+ end
208
+
209
+ def edits(node)
210
+ text_document_edit(node)[:edits]
211
+ end
212
+
213
+ def to_text_document(thing)
214
+ case thing
215
+ when Node
216
+ {
217
+ uri: file_uri(thing.theme_file&.path),
218
+ version: thing.theme_file&.version,
219
+ }
220
+ when ThemeFile
221
+ {
222
+ uri: file_uri(thing.path),
223
+ version: thing.version,
224
+ }
225
+ else
226
+ raise ArgumentError
227
+ end
228
+ end
229
+
230
+ def absolute_path(node)
231
+ node.theme_file&.path
232
+ end
233
+
234
+ def character_range_position(node, character_range)
235
+ return unless character_range
236
+ source = node.theme_file.source
237
+ StrictPosition.new(
238
+ source[character_range],
239
+ source,
240
+ character_range.begin,
241
+ )
242
+ end
243
+
244
+ # @param node [ThemeCheck::Node]
245
+ def range(node)
246
+ {
247
+ start: start_location(node),
248
+ end: end_location(node),
249
+ }
250
+ end
251
+
252
+ def start_location(node)
253
+ {
254
+ line: node.start_row,
255
+ character: node.start_column,
256
+ }
257
+ end
258
+
259
+ def end_location(node)
260
+ {
261
+ line: node.end_row,
262
+ character: node.end_column,
263
+ }
264
+ end
265
+ end
266
+ end
267
+ end