diff --git a/spec/reproduction_76_spec.cr b/spec/reproduction_76_spec.cr new file mode 100644 index 0000000..526ba16 --- /dev/null +++ b/spec/reproduction_76_spec.cr @@ -0,0 +1,59 @@ +require "spec" +require "lsp/server" +require "../src/crystalline/requires" +require "../src/crystalline/*" + +class FakeServer < LSP::Server + def initialize + super(IO::Memory.new, IO::Memory.new) + @client_capabilities = LSP::ClientCapabilities.new + end +end + +describe Crystalline::Workspace do + it "fixes #76: does not trigger completion inside comments" do + server = FakeServer.new + workspace = Crystalline::Workspace.new(server, "file:///tmp") + + file_uri = URI.parse("file:///tmp/test_comment.cr") + # A file with a dot inside a comment + content = <<-CRYSTAL + # This is a comment. + puts 1 + CRYSTAL + + workspace.open_document(LSP::DidOpenTextDocumentParams.new( + text_document: LSP::TextDocumentItem.new( + uri: file_uri.to_s, + language_id: "crystal", + version: 1, + text: content + ) + )) + + # Try to trigger completion at the dot in the comment + # Line 0, character 19 (the dot at the end of "comment.") + pos = LSP::Position.new(line: 0, character: 19) + + result = workspace.completion(server, file_uri, pos, ".") + + # It should return nil early because it's in a comment. + result.should be_nil + + # Test case: # inside a string should NOT be a comment + content_with_string = <<-CRYSTAL + x = "this is # not a comment." + CRYSTAL + workspace.update_document(server, LSP::DidChangeTextDocumentParams.new( + text_document: LSP::VersionedTextDocumentIdentifier.new(uri: file_uri.to_s, version: 2), + content_changes: [ + LSP::DidChangeTextDocumentParams::TextDocumentContentChangeEvent.new( + text: content_with_string + ), + ] + )) + # Dot at end of string. Line 0, character 31. + pos_in_string = LSP::Position.new(line: 0, character: 31) + workspace.completion(server, file_uri, pos_in_string, ".") + end +end diff --git a/src/crystalline/analysis/analysis.cr b/src/crystalline/analysis/analysis.cr index c49f8df..75fc882 100644 --- a/src/crystalline/analysis/analysis.cr +++ b/src/crystalline/analysis/analysis.cr @@ -10,11 +10,17 @@ module Crystalline::Analysis end {% end %} - private def self.spawn_dedicated(*, name : String? = nil, &block) - fiber = Fiber.new(name, &block) - {% if flag?(:preview_mt) %} fiber.set_current_thread(@@dedicated_thread) {% end %} - fiber.enqueue - fiber + private def self.spawn_dedicated(*, name : String? = nil, &block : -> _) + captured_block = block + {% if flag?(:preview_mt) %} + fiber = Fiber.new(name, &captured_block) + fiber.set_current_thread(@@dedicated_thread) + fiber.enqueue + fiber + {% else %} + captured_block.call + Fiber.current + {% end %} end # Compile a target *file_uri*. diff --git a/src/crystalline/progress.cr b/src/crystalline/progress.cr index 847893b..deef396 100644 --- a/src/crystalline/progress.cr +++ b/src/crystalline/progress.cr @@ -8,14 +8,26 @@ class Crystalline::Progress end def report(server, *, async = false, &cb : Proc(String?)) - create_request = LSP::WorkDoneProgressCreateRequest.new( - id: 0, - params: LSP::WorkDoneProgressCreateParams.new( - token: @token, - ), - ) + if server.client_capabilities.window.try &.work_done_progress + create_request = LSP::WorkDoneProgressCreateRequest.new( + id: 0, + params: LSP::WorkDoneProgressCreateParams.new( + token: @token, + ), + ) - create_request.on_response { + create_request.on_response { + if async + spawn { + report_callback(server, &cb) + } + else + report_callback(server, &cb) + end + } + + server.send(create_request) + else if async spawn { report_callback(server, &cb) @@ -23,9 +35,7 @@ class Crystalline::Progress else report_callback(server, &cb) end - } - - server.send(create_request) + end end def send_progress_start(server) diff --git a/src/crystalline/workspace.cr b/src/crystalline/workspace.cr index c02f195..8f50a7b 100644 --- a/src/crystalline/workspace.cr +++ b/src/crystalline/workspace.cr @@ -159,7 +159,7 @@ class Crystalline::Workspace return cached_result unless cached_result.nil? && discard_nil_cached_result end - sync_channel = Channel(Crystal::Compiler::Result?).new + sync_channel = Channel(Crystal::Compiler::Result?).new(1) progress.report(server) do file_overrides = nil @@ -172,16 +172,13 @@ class Crystalline::Workspace @opened_documents.each { |uri_str, text_document| contents = text_overrides.try(&.[uri_str]?) || text_document.contents contents = fix_source(contents) - - if target_string == uri_str - # If the entry point itself needs to be loaded from memory. - sources = [ - Crystal::Compiler::Source.new(target.decoded_path, contents), - ] - end - file_path = URI.parse(uri_str).decoded_path - file_overrides[file_path] = contents + file_overrides[URI.parse(uri_str).decoded_path] = contents } + + if (doc = @opened_documents[target_string]?) + contents = text_overrides.try(&.[target_string]?) || doc.contents + sources = [Crystal::Compiler::Source.new(target.decoded_path, fix_source(contents))] + end end lib_path = project.try(&.default_lib_path) @@ -361,11 +358,51 @@ class Crystalline::Workspace nil end - def completion(server : LSP::Server, file_uri : URI, position : LSP::Position, trigger_character : String?) + def completion(server : LSP::Server, file_uri : URI, position : LSP::Position, trigger_character : String?) : LSP::CompletionList? text_document = @opened_documents[file_uri.to_s]? return unless text_document - # LSP::Log.info { "completion: #{trigger_character}"} + # Check if we are inside a comment. + current_line = text_document.contents.lines(chomp: false)[position.line]? + if current_line + # Convert UTF-16 character position to byte offset for the lexer. + byte_offset = 0 + char_offset = 0 + current_line.each_char do |char| + # In UTF-16, characters > 0xFFFF take 2 code units (surrogate pair). + u16_size = char.ord > 0xFFFF ? 2 : 1 + break if char_offset + u16_size > position.character + + byte_offset += char.bytesize + char_offset += u16_size + end + # Lexer column numbers are 1-based byte offsets. + lexer_column = byte_offset + 1 + + lexer = Crystal::Lexer.new(current_line) + begin + loop do + token = lexer.next_token + break if token.type.eof? + + if (loc = token.location) + break if loc.column_number > lexer_column + end + + if token.type.comment? + if (loc = token.location) + token_start = loc.column_number + # If the cursor is at or after the start of the comment. + if lexer_column >= token_start + return nil + end + end + end + end + rescue Crystal::SyntaxException + # Ignore syntax errors while typing + end + end document_lines = fix_source(text_document.contents).lines(chomp: false) left_offset = 0 @@ -420,7 +457,6 @@ class Crystalline::Workspace result = self.compile( server, file_uri, - in_memory: true, discard_nil_cached_result: true, wants_doc: true, text_overrides: text_overrides,