Skip to content
This repository has been archived by the owner on Jul 27, 2024. It is now read-only.

Add Snippet Completion + Document Links #223

Merged
merged 1 commit into from
Mar 23, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions lib/theme_check/in_memory_storage.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason I'm doing this is because I would otherwise have absolute paths in the InMemoryStorage. So we'd have duplicate entries for absolute paths and relative paths. I could also revert this and only have absolute paths.

end

def write(name, content)
@files[name] = content
@files[relative_path(name)] = content
end

def files
Expand Down
2 changes: 2 additions & 0 deletions lib/theme_check/language_server.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
# 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"
require_relative "language_server/position_helper"
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
Expand Down
2 changes: 1 addition & 1 deletion lib/theme_check/language_server/completion_engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions lib/theme_check/language_server/completion_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ def inherited(subclass)
end
end

def initialize(storage = InMemoryStorage.new)
@storage = storage
end

def completions(content, cursor)
raise NotImplementedError
end
Expand Down
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions lib/theme_check/language_server/constants.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

module ThemeCheck
module LanguageServer
PARTIAL_RENDER = %r{
\{\%\s*render\s+'(?<partial>[^']*)'|
\{\%\s*render\s+"(?<partial>[^"]*)"
}mix
end
end
47 changes: 47 additions & 0 deletions lib/theme_check/language_server/document_link_engine.rb
Original file line number Diff line number Diff line change
@@ -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|
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this file specific to render support?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now, yes. But we might want to add support for things that get piped to asset_url in the future.

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
35 changes: 33 additions & 2 deletions lib/theme_check/language_server/handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class Handler
triggerCharacters: ['.', '{{ ', '{% '],
context: true,
},
documentLinkProvider: true,
textDocumentSync: {
openClose: true,
change: TextDocumentSyncKind::FULL,
Expand All @@ -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,
Expand Down Expand Up @@ -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')
Expand All @@ -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, ""] }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious why you need to do this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because I need a list of snippets from the RenderSnippetCompletionProvider. The InMemoryStorage doesn't have anything in it and I didn't want to have both an InMemoryStorage instance for buffers and a FSStorage for files just so that the render snippet completion provider was able to list those files.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see! Because that's what InMemoryStorage.new expects! 👍

.to_h

InMemoryStorage.new(files, root)
end

def text_document_uri(params)
params.dig('textDocument', 'uri').sub('file://', '')
end
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions lib/theme_check/language_server/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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")
Expand Down
56 changes: 56 additions & 0 deletions test/language_server/document_link_engine_test.rb
Original file line number Diff line number Diff line change
@@ -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