theme-check 1.7.2 → 1.9.2

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 (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