diff --git a/lib/theme_check/in_memory_storage.rb b/lib/theme_check/in_memory_storage.rb index 733b2f30..815d9db9 100644 --- a/lib/theme_check/in_memory_storage.rb +++ b/lib/theme_check/in_memory_storage.rb @@ -6,20 +6,28 @@ # as a big hash already, leave it like that and save yourself some IO. module ThemeCheck class InMemoryStorage < Storage - def initialize(files = {}) + def initialize(files = {}, root = nil) @files = files + @root = root end def path(name) + return File.join(@root, name) unless @root.nil? + name + end + + def relative_path(name) + path = Pathname.new(name) + return path.relative_path_from(@root).to_s unless path.relative? || @root.nil? name end def read(name) - @files[name] + @files[relative_path(name)] end def write(name, content) - @files[name] = content + @files[relative_path(name)] = content end def files diff --git a/lib/theme_check/language_server.rb b/lib/theme_check/language_server.rb index f49e5c97..b374d596 100644 --- a/lib/theme_check/language_server.rb +++ b/lib/theme_check/language_server.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true require_relative "language_server/protocol" +require_relative "language_server/constants" require_relative "language_server/handler" require_relative "language_server/server" require_relative "language_server/tokens" @@ -7,6 +8,7 @@ require_relative "language_server/completion_helper" require_relative "language_server/completion_provider" require_relative "language_server/completion_engine" +require_relative "language_server/document_link_engine" Dir[__dir__ + "/language_server/completion_providers/*.rb"].each do |file| require file diff --git a/lib/theme_check/language_server/completion_engine.rb b/lib/theme_check/language_server/completion_engine.rb index d511d407..a8435b05 100644 --- a/lib/theme_check/language_server/completion_engine.rb +++ b/lib/theme_check/language_server/completion_engine.rb @@ -7,7 +7,7 @@ class CompletionEngine def initialize(storage) @storage = storage - @providers = CompletionProvider.all.map(&:new) + @providers = CompletionProvider.all.map { |x| x.new(storage) } end def completions(name, line, col) diff --git a/lib/theme_check/language_server/completion_provider.rb b/lib/theme_check/language_server/completion_provider.rb index 28df30a3..e0521831 100644 --- a/lib/theme_check/language_server/completion_provider.rb +++ b/lib/theme_check/language_server/completion_provider.rb @@ -16,6 +16,10 @@ def inherited(subclass) end end + def initialize(storage = InMemoryStorage.new) + @storage = storage + end + def completions(content, cursor) raise NotImplementedError end diff --git a/lib/theme_check/language_server/completion_providers/render_snippet_completion_provider.rb b/lib/theme_check/language_server/completion_providers/render_snippet_completion_provider.rb new file mode 100644 index 00000000..598dcfe6 --- /dev/null +++ b/lib/theme_check/language_server/completion_providers/render_snippet_completion_provider.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module ThemeCheck + module LanguageServer + class RenderSnippetCompletionProvider < CompletionProvider + def completions(content, cursor) + return [] unless cursor_on_quoted_argument?(content, cursor) + partial = snippet(content) || '' + snippets + .select { |x| x.starts_with?(partial) } + .map { |x| snippet_to_completion(x) } + end + + private + + def cursor_on_quoted_argument?(content, cursor) + match = content.match(PARTIAL_RENDER) + return false if match.nil? + match.begin(:partial) <= cursor && cursor <= match.end(:partial) + end + + def snippet(content) + match = content.match(PARTIAL_RENDER) + return if match.nil? + match[:partial] + end + + def snippets + @storage + .files + .select { |x| x.include?('snippets/') } + end + + def snippet_to_completion(file) + { + label: File.basename(file, '.liquid'), + kind: CompletionItemKinds::SNIPPET, + detail: file, + } + end + end + end +end diff --git a/lib/theme_check/language_server/constants.rb b/lib/theme_check/language_server/constants.rb new file mode 100644 index 00000000..68c5a73d --- /dev/null +++ b/lib/theme_check/language_server/constants.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module ThemeCheck + module LanguageServer + PARTIAL_RENDER = %r{ + \{\%\s*render\s+'(?[^']*)'| + \{\%\s*render\s+"(?[^"]*)" + }mix + end +end diff --git a/lib/theme_check/language_server/document_link_engine.rb b/lib/theme_check/language_server/document_link_engine.rb new file mode 100644 index 00000000..3ff1f473 --- /dev/null +++ b/lib/theme_check/language_server/document_link_engine.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module ThemeCheck + module LanguageServer + class DocumentLinkEngine + include PositionHelper + include RegexHelpers + + def initialize(storage) + @storage = storage + end + + def document_links(uri) + buffer = @storage.read(uri) + matches(buffer, PARTIAL_RENDER).map do |match| + start_line, start_character = from_index_to_line_column( + buffer, + match.begin(:partial), + ) + + end_line, end_character = from_index_to_line_column( + buffer, + match.end(:partial) + ) + + { + target: link(match[:partial]), + range: { + start: { + line: start_line, + character: start_character, + }, + end: { + line: end_line, + character: end_character, + }, + }, + } + end + end + + def link(partial) + 'file://' + @storage.path('snippets/' + partial + '.liquid') + end + end + end +end diff --git a/lib/theme_check/language_server/handler.rb b/lib/theme_check/language_server/handler.rb index 52fe4143..b9e5b951 100644 --- a/lib/theme_check/language_server/handler.rb +++ b/lib/theme_check/language_server/handler.rb @@ -8,6 +8,7 @@ class Handler triggerCharacters: ['.', '{{ ', '{% '], context: true, }, + documentLinkProvider: true, textDocumentSync: { openClose: true, change: TextDocumentSyncKind::FULL, @@ -19,12 +20,13 @@ class Handler def initialize(server) @server = server @previously_reported_files = Set.new - @storage = InMemoryStorage.new - @completion_engine = CompletionEngine.new(@storage) end def on_initialize(id, params) @root_path = params["rootPath"] + @storage = in_memory_storage(@root_path) + @completion_engine = CompletionEngine.new(@storage) + @document_link_engine = DocumentLinkEngine.new(@storage) # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#responseMessage send_response( id: id, @@ -59,6 +61,14 @@ def on_text_document_did_save(_id, params) analyze_and_send_offenses(text_document_uri(params)) end + def on_text_document_document_link(id, params) + uri = text_document_uri(params) + send_response( + id: id, + result: document_links(uri) + ) + end + def on_text_document_completion(id, params) uri = text_document_uri(params) line = params.dig('position', 'line') @@ -71,6 +81,23 @@ def on_text_document_completion(id, params) private + def in_memory_storage(root) + config = ThemeCheck::Config.from_path(root) + + # Make a real FS to get the files from the snippets folder + fs = ThemeCheck::FileSystemStorage.new( + config.root, + ignored_patterns: config.ignored_patterns + ) + + # Turn that into a hash of empty buffers + files = fs.files + .map { |fn| [fn, ""] } + .to_h + + InMemoryStorage.new(files, root) + end + def text_document_uri(params) params.dig('textDocument', 'uri').sub('file://', '') end @@ -108,6 +135,10 @@ def completions(uri, line, col) @completion_engine.completions(uri, line, col) end + def document_links(uri) + @document_link_engine.document_links(uri) + end + def send_diagnostics(offenses) reported_files = Set.new diff --git a/lib/theme_check/language_server/server.rb b/lib/theme_check/language_server/server.rb index 4a54cdeb..589103ce 100644 --- a/lib/theme_check/language_server/server.rb +++ b/lib/theme_check/language_server/server.rb @@ -16,7 +16,7 @@ class Server def initialize( in_stream: STDIN, out_stream: STDOUT, - err_stream: $DEBUG ? File.open('/tmp/lsp.log', 'a') : STDERR, + err_stream: STDERR, should_raise_errors: false ) validate!([in_stream, out_stream, err_stream]) @@ -51,7 +51,7 @@ def listen def send_response(response) response_body = JSON.dump(response) - log(response_body) if $DEBUG + log(JSON.pretty_generate(response)) if $DEBUG @out.write("Content-Length: #{response_body.size}\r\n") @out.write("\r\n") diff --git a/test/language_server/document_link_engine_test.rb b/test/language_server/document_link_engine_test.rb new file mode 100644 index 00000000..5724fdb9 --- /dev/null +++ b/test/language_server/document_link_engine_test.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true +require "test_helper" + +module ThemeCheck + module LanguageServer + class DocumentLinkEngineTest < Minitest::Test + include PositionHelper + + def test_makes_links_out_of_render_tags + content = <<~LIQUID + {% render 'a' %} + {% render "b" %} + LIQUID + + engine = make_engine( + "snippets/a.liquid" => "", + "snippets/b.liquid" => "", + "templates/product.liquid" => content, + ) + + assert_links_include("a", content, engine.document_links("templates/product.liquid")) + assert_links_include("b", content, engine.document_links("templates/product.liquid")) + end + + def assert_links_include(needle, content, links) + target = "file:///tmp/snippets/#{needle}.liquid" + match = links.find { |x| x[:target] == target } + + refute_nil(match, "Should find a document_link with target == '#{target}'") + + assert_equal( + from_index_to_line_column(content, content.index(needle)), + [ + match.dig(:range, :start, :line), + match.dig(:range, :start, :character), + ], + ) + + assert_equal( + from_index_to_line_column(content, content.index(needle) + 1), + [ + match.dig(:range, :end, :line), + match.dig(:range, :end, :character), + ], + ) + end + + private + + def make_engine(files) + storage = InMemoryStorage.new(files, "/tmp") + DocumentLinkEngine.new(storage) + end + end + end +end