mui-lsp 0.1.0
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.
- checksums.yaml +7 -0
- data/.rubocop_todo.yml +73 -0
- data/CHANGELOG.md +61 -0
- data/LICENSE.txt +21 -0
- data/README.md +188 -0
- data/Rakefile +12 -0
- data/lib/mui/lsp/client.rb +290 -0
- data/lib/mui/lsp/handlers/base.rb +92 -0
- data/lib/mui/lsp/handlers/completion.rb +139 -0
- data/lib/mui/lsp/handlers/definition.rb +99 -0
- data/lib/mui/lsp/handlers/diagnostics.rb +161 -0
- data/lib/mui/lsp/handlers/hover.rb +55 -0
- data/lib/mui/lsp/handlers/references.rb +65 -0
- data/lib/mui/lsp/handlers.rb +8 -0
- data/lib/mui/lsp/highlighters/diagnostic_highlighter.rb +71 -0
- data/lib/mui/lsp/json_rpc_io.rb +120 -0
- data/lib/mui/lsp/manager.rb +366 -0
- data/lib/mui/lsp/plugin.rb +539 -0
- data/lib/mui/lsp/protocol/diagnostic.rb +88 -0
- data/lib/mui/lsp/protocol/location.rb +42 -0
- data/lib/mui/lsp/protocol/position.rb +31 -0
- data/lib/mui/lsp/protocol/range.rb +34 -0
- data/lib/mui/lsp/protocol.rb +6 -0
- data/lib/mui/lsp/request_manager.rb +72 -0
- data/lib/mui/lsp/server_config.rb +115 -0
- data/lib/mui/lsp/text_document_sync.rb +149 -0
- data/lib/mui/lsp/version.rb +7 -0
- data/lib/mui/lsp.rb +19 -0
- data/lib/mui_lsp.rb +3 -0
- data/sig/mui/lsp.rbs +6 -0
- metadata +89 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
module Lsp
|
|
5
|
+
module Handlers
|
|
6
|
+
# Base class for LSP response handlers
|
|
7
|
+
class Base
|
|
8
|
+
attr_reader :editor, :client
|
|
9
|
+
|
|
10
|
+
def initialize(editor:, client:)
|
|
11
|
+
@editor = editor
|
|
12
|
+
@client = client
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def handle(result, error)
|
|
16
|
+
if error
|
|
17
|
+
handle_error(error)
|
|
18
|
+
elsif result
|
|
19
|
+
handle_result(result)
|
|
20
|
+
else
|
|
21
|
+
handle_empty
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
protected
|
|
26
|
+
|
|
27
|
+
def handle_result(result)
|
|
28
|
+
raise NotImplementedError, "Subclass must implement #handle_result"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def handle_error(error)
|
|
32
|
+
msg = error["message"] || "Unknown LSP error"
|
|
33
|
+
code = error["code"]
|
|
34
|
+
@editor.message = "LSP Error (#{code}): #{msg}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def handle_empty
|
|
38
|
+
@editor.message = "No information available"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def current_file_path
|
|
42
|
+
@editor.current_buffer&.file_path
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def current_uri
|
|
46
|
+
path = current_file_path
|
|
47
|
+
path ? TextDocumentSync.path_to_uri(path) : nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def cursor_position
|
|
51
|
+
window = @editor.current_window
|
|
52
|
+
return nil unless window
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
line: window.cursor_row,
|
|
56
|
+
character: window.cursor_col
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def markup_to_text(content)
|
|
61
|
+
return nil unless content
|
|
62
|
+
|
|
63
|
+
case content
|
|
64
|
+
when String
|
|
65
|
+
content
|
|
66
|
+
when Hash
|
|
67
|
+
value = content["value"] || content[:value]
|
|
68
|
+
case content["kind"] || content[:kind]
|
|
69
|
+
when "markdown"
|
|
70
|
+
# Strip basic markdown formatting
|
|
71
|
+
strip_markdown(value)
|
|
72
|
+
else
|
|
73
|
+
value
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def strip_markdown(text)
|
|
79
|
+
return nil unless text
|
|
80
|
+
|
|
81
|
+
text
|
|
82
|
+
.gsub(/```\w*\n?/, "") # Remove code fence markers
|
|
83
|
+
.gsub(/`([^`]+)`/, '\1') # Remove inline code markers
|
|
84
|
+
.gsub(/\*\*([^*]+)\*\*/, '\1') # Remove bold
|
|
85
|
+
.gsub(/\*([^*]+)\*/, '\1') # Remove italic
|
|
86
|
+
.gsub(/^\s*#+\s*/, "") # Remove headings
|
|
87
|
+
.strip
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
module Lsp
|
|
5
|
+
module Handlers
|
|
6
|
+
# Handler for textDocument/completion responses
|
|
7
|
+
class Completion < Base
|
|
8
|
+
# CompletionItemKind constants
|
|
9
|
+
module Kind
|
|
10
|
+
TEXT = 1
|
|
11
|
+
METHOD = 2
|
|
12
|
+
FUNCTION = 3
|
|
13
|
+
CONSTRUCTOR = 4
|
|
14
|
+
FIELD = 5
|
|
15
|
+
VARIABLE = 6
|
|
16
|
+
CLASS = 7
|
|
17
|
+
INTERFACE = 8
|
|
18
|
+
MODULE = 9
|
|
19
|
+
PROPERTY = 10
|
|
20
|
+
UNIT = 11
|
|
21
|
+
VALUE = 12
|
|
22
|
+
ENUM = 13
|
|
23
|
+
KEYWORD = 14
|
|
24
|
+
SNIPPET = 15
|
|
25
|
+
COLOR = 16
|
|
26
|
+
FILE = 17
|
|
27
|
+
REFERENCE = 18
|
|
28
|
+
FOLDER = 19
|
|
29
|
+
ENUM_MEMBER = 20
|
|
30
|
+
CONSTANT = 21
|
|
31
|
+
STRUCT = 22
|
|
32
|
+
EVENT = 23
|
|
33
|
+
OPERATOR = 24
|
|
34
|
+
TYPE_PARAMETER = 25
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
protected
|
|
38
|
+
|
|
39
|
+
def handle_result(result)
|
|
40
|
+
items = extract_items(result)
|
|
41
|
+
return handle_empty if items.empty?
|
|
42
|
+
|
|
43
|
+
show_completion_menu(items)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def handle_empty
|
|
47
|
+
@editor.message = "No completions available"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def extract_items(result)
|
|
53
|
+
case result
|
|
54
|
+
when Array
|
|
55
|
+
result
|
|
56
|
+
when Hash
|
|
57
|
+
# CompletionList
|
|
58
|
+
result["items"] || []
|
|
59
|
+
else
|
|
60
|
+
[]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def show_completion_menu(items)
|
|
65
|
+
# Format items for display
|
|
66
|
+
formatted = items.map do |item|
|
|
67
|
+
{
|
|
68
|
+
label: item["label"],
|
|
69
|
+
kind: item["kind"],
|
|
70
|
+
detail: item["detail"],
|
|
71
|
+
documentation: item["documentation"],
|
|
72
|
+
insert_text: item["insertText"] || item["label"],
|
|
73
|
+
text_edit: item["textEdit"]
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Sort by sortText or label
|
|
78
|
+
formatted.sort_by! do |item|
|
|
79
|
+
items.find { |i| i["label"] == item[:label] }&.dig("sortText") || item[:label]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Display completion menu or use Mui's completion system
|
|
83
|
+
display_completions(formatted)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def display_completions(items)
|
|
87
|
+
# For now, show first few items in message
|
|
88
|
+
# TODO: Integrate with Mui's popup menu or completion system
|
|
89
|
+
count = items.length
|
|
90
|
+
|
|
91
|
+
# Build a summary message
|
|
92
|
+
first_items = items.first(3).map { |item| item[:label] }
|
|
93
|
+
summary = first_items.join(", ")
|
|
94
|
+
summary += ", ..." if count > 3
|
|
95
|
+
|
|
96
|
+
@editor.message = "#{count} completion#{"s" unless count == 1}: #{summary}"
|
|
97
|
+
|
|
98
|
+
# Store items for potential insertion
|
|
99
|
+
store_completions(items)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def store_completions(items)
|
|
103
|
+
@editor.instance_variable_set(:@lsp_completions, items)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def kind_to_string(kind)
|
|
107
|
+
case kind
|
|
108
|
+
when Kind::TEXT then "Text"
|
|
109
|
+
when Kind::METHOD then "Method"
|
|
110
|
+
when Kind::FUNCTION then "Function"
|
|
111
|
+
when Kind::CONSTRUCTOR then "Constructor"
|
|
112
|
+
when Kind::FIELD then "Field"
|
|
113
|
+
when Kind::VARIABLE then "Variable"
|
|
114
|
+
when Kind::CLASS then "Class"
|
|
115
|
+
when Kind::INTERFACE then "Interface"
|
|
116
|
+
when Kind::MODULE then "Module"
|
|
117
|
+
when Kind::PROPERTY then "Property"
|
|
118
|
+
when Kind::UNIT then "Unit"
|
|
119
|
+
when Kind::VALUE then "Value"
|
|
120
|
+
when Kind::ENUM then "Enum"
|
|
121
|
+
when Kind::KEYWORD then "Keyword"
|
|
122
|
+
when Kind::SNIPPET then "Snippet"
|
|
123
|
+
when Kind::COLOR then "Color"
|
|
124
|
+
when Kind::FILE then "File"
|
|
125
|
+
when Kind::REFERENCE then "Reference"
|
|
126
|
+
when Kind::FOLDER then "Folder"
|
|
127
|
+
when Kind::ENUM_MEMBER then "EnumMember"
|
|
128
|
+
when Kind::CONSTANT then "Constant"
|
|
129
|
+
when Kind::STRUCT then "Struct"
|
|
130
|
+
when Kind::EVENT then "Event"
|
|
131
|
+
when Kind::OPERATOR then "Operator"
|
|
132
|
+
when Kind::TYPE_PARAMETER then "TypeParam"
|
|
133
|
+
else "Unknown"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
module Lsp
|
|
5
|
+
module Handlers
|
|
6
|
+
# Handler for textDocument/definition responses
|
|
7
|
+
class Definition < Base
|
|
8
|
+
protected
|
|
9
|
+
|
|
10
|
+
def handle_result(result)
|
|
11
|
+
locations = normalize_locations(result)
|
|
12
|
+
return handle_empty if locations.empty?
|
|
13
|
+
|
|
14
|
+
if locations.length == 1
|
|
15
|
+
jump_to_location(locations.first)
|
|
16
|
+
else
|
|
17
|
+
show_location_list(locations)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def handle_empty
|
|
22
|
+
@editor.message = "No definition found"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def normalize_locations(result)
|
|
28
|
+
case result
|
|
29
|
+
when Array
|
|
30
|
+
result.map { |loc| parse_location(loc) }.compact
|
|
31
|
+
when Hash
|
|
32
|
+
location = parse_location(result)
|
|
33
|
+
location ? [location] : []
|
|
34
|
+
else
|
|
35
|
+
[]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def parse_location(data)
|
|
40
|
+
return nil unless data
|
|
41
|
+
|
|
42
|
+
# Handle both Location and LocationLink
|
|
43
|
+
if data["targetUri"]
|
|
44
|
+
# LocationLink
|
|
45
|
+
Protocol::Location.new(
|
|
46
|
+
uri: data["targetUri"],
|
|
47
|
+
range: data["targetSelectionRange"] || data["targetRange"]
|
|
48
|
+
)
|
|
49
|
+
elsif data["uri"]
|
|
50
|
+
# Location
|
|
51
|
+
Protocol::Location.from_hash(data)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def jump_to_location(location)
|
|
56
|
+
file_path = location.file_path
|
|
57
|
+
unless file_path
|
|
58
|
+
@editor.message = "Cannot open: #{location.uri}"
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
line = location.range.start.line
|
|
63
|
+
character = location.range.start.character
|
|
64
|
+
|
|
65
|
+
# Open the file in current window
|
|
66
|
+
current_buffer = @editor.buffer
|
|
67
|
+
if current_buffer.file_path != file_path
|
|
68
|
+
# Need to open a different file
|
|
69
|
+
new_buffer = Mui::Buffer.new
|
|
70
|
+
new_buffer.load(file_path)
|
|
71
|
+
@editor.window.buffer = new_buffer
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Jump to position
|
|
75
|
+
window = @editor.window
|
|
76
|
+
return unless window
|
|
77
|
+
|
|
78
|
+
window.cursor_row = line
|
|
79
|
+
window.cursor_col = character
|
|
80
|
+
window.ensure_cursor_visible
|
|
81
|
+
|
|
82
|
+
@editor.message = "#{File.basename(file_path)}:#{line + 1}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def show_location_list(locations)
|
|
86
|
+
# Show a list of locations for the user to choose from
|
|
87
|
+
items = locations.map do |loc|
|
|
88
|
+
file_path = loc.file_path || loc.uri
|
|
89
|
+
line = loc.range.start.line + 1
|
|
90
|
+
"#{file_path}:#{line}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
@editor.message = "Found #{locations.length} definitions: #{items.first}..."
|
|
94
|
+
# TODO: Integrate with quickfix list or popup menu when available
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
module Lsp
|
|
5
|
+
module Handlers
|
|
6
|
+
# Handler for textDocument/publishDiagnostics notifications
|
|
7
|
+
class Diagnostics
|
|
8
|
+
attr_reader :editor, :diagnostics_by_uri
|
|
9
|
+
|
|
10
|
+
def initialize(editor:)
|
|
11
|
+
@editor = editor
|
|
12
|
+
@diagnostics_by_uri = {}
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def handle(params)
|
|
17
|
+
uri = params["uri"]
|
|
18
|
+
raw_diagnostics = params["diagnostics"] || []
|
|
19
|
+
|
|
20
|
+
diagnostics = raw_diagnostics.map do |d|
|
|
21
|
+
Protocol::Diagnostic.from_hash(d)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
if diagnostics.empty?
|
|
26
|
+
@diagnostics_by_uri.delete(uri)
|
|
27
|
+
else
|
|
28
|
+
@diagnostics_by_uri[uri] = diagnostics
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
update_display(uri, diagnostics)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def diagnostics_for(uri)
|
|
36
|
+
@mutex.synchronize { @diagnostics_by_uri[uri] || [] }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def all_diagnostics
|
|
40
|
+
@mutex.synchronize { @diagnostics_by_uri.dup }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def diagnostics_at_line(uri, line)
|
|
44
|
+
diagnostics_for(uri).select do |d|
|
|
45
|
+
line.between?(d.range.start.line, d.range.end.line)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def clear_all
|
|
50
|
+
@mutex.synchronize { @diagnostics_by_uri.clear }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def clear(uri)
|
|
54
|
+
@mutex.synchronize { @diagnostics_by_uri.delete(uri) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def counts(uri = nil)
|
|
58
|
+
diagnostics = uri ? diagnostics_for(uri) : @mutex.synchronize { @diagnostics_by_uri.values.flatten }
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
error: diagnostics.count(&:error?),
|
|
62
|
+
warning: diagnostics.count(&:warning?),
|
|
63
|
+
information: diagnostics.count(&:information?),
|
|
64
|
+
hint: diagnostics.count(&:hint?)
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def summary(uri = nil)
|
|
69
|
+
c = counts(uri)
|
|
70
|
+
parts = []
|
|
71
|
+
parts << "E:#{c[:error]}" if c[:error].positive?
|
|
72
|
+
parts << "W:#{c[:warning]}" if c[:warning].positive?
|
|
73
|
+
parts << "I:#{c[:information]}" if c[:information].positive?
|
|
74
|
+
parts << "H:#{c[:hint]}" if c[:hint].positive?
|
|
75
|
+
parts.empty? ? "" : parts.join(" ")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def update_display(uri, diagnostics)
|
|
81
|
+
# Update message area with summary
|
|
82
|
+
if diagnostics.empty?
|
|
83
|
+
@editor.message = "Diagnostics cleared"
|
|
84
|
+
else
|
|
85
|
+
error_count = diagnostics.count(&:error?)
|
|
86
|
+
warning_count = diagnostics.count(&:warning?)
|
|
87
|
+
@editor.message = "#{diagnostics.length} diagnostics (#{error_count} errors, #{warning_count} warnings)"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Apply custom highlighter if available
|
|
91
|
+
apply_highlights(uri, diagnostics)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def apply_highlights(uri, diagnostics)
|
|
95
|
+
file_path = TextDocumentSync.uri_to_path(uri)
|
|
96
|
+
return unless file_path
|
|
97
|
+
|
|
98
|
+
# Get current buffer and check if it matches
|
|
99
|
+
buffer = @editor.buffer
|
|
100
|
+
return unless buffer&.file_path
|
|
101
|
+
|
|
102
|
+
# Compare paths - need to expand to handle relative vs absolute
|
|
103
|
+
buffer_path = File.expand_path(buffer.file_path)
|
|
104
|
+
diag_path = File.expand_path(file_path)
|
|
105
|
+
return unless buffer_path == diag_path
|
|
106
|
+
|
|
107
|
+
# Remove existing diagnostic highlighter
|
|
108
|
+
had_highlighter = buffer.custom_highlighter?(:lsp_diagnostics)
|
|
109
|
+
buffer.remove_custom_highlighter(:lsp_diagnostics)
|
|
110
|
+
|
|
111
|
+
if diagnostics.empty?
|
|
112
|
+
@editor.window&.refresh_highlighters if had_highlighter
|
|
113
|
+
return
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Create and add new highlighter
|
|
117
|
+
color_scheme = @editor.color_scheme
|
|
118
|
+
highlighter = Highlighters::DiagnosticHighlighter.new(color_scheme, diagnostics)
|
|
119
|
+
buffer.add_custom_highlighter(:lsp_diagnostics, highlighter)
|
|
120
|
+
|
|
121
|
+
# Refresh window's highlighters to pick up the change
|
|
122
|
+
@editor.window&.refresh_highlighters
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def build_highlighter(diagnostics)
|
|
126
|
+
# Return a lambda that highlights diagnostic ranges
|
|
127
|
+
lambda do |line_index, line_content|
|
|
128
|
+
highlights = []
|
|
129
|
+
|
|
130
|
+
diagnostics.each do |d|
|
|
131
|
+
next unless line_index.between?(d.range.start.line, d.range.end.line)
|
|
132
|
+
|
|
133
|
+
start_col = d.range.start.line == line_index ? d.range.start.character : 0
|
|
134
|
+
end_col = d.range.end.line == line_index ? d.range.end.character : line_content.length
|
|
135
|
+
|
|
136
|
+
color = severity_color(d.severity)
|
|
137
|
+
highlights << { start: start_col, end: end_col, color: color }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
highlights
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def severity_color(severity)
|
|
145
|
+
case severity
|
|
146
|
+
when Protocol::DiagnosticSeverity::ERROR
|
|
147
|
+
:red
|
|
148
|
+
when Protocol::DiagnosticSeverity::WARNING
|
|
149
|
+
:yellow
|
|
150
|
+
when Protocol::DiagnosticSeverity::INFORMATION
|
|
151
|
+
:blue
|
|
152
|
+
when Protocol::DiagnosticSeverity::HINT
|
|
153
|
+
:cyan
|
|
154
|
+
else
|
|
155
|
+
:default
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
module Lsp
|
|
5
|
+
module Handlers
|
|
6
|
+
# Handler for textDocument/hover responses
|
|
7
|
+
class Hover < Base
|
|
8
|
+
protected
|
|
9
|
+
|
|
10
|
+
def handle_result(result)
|
|
11
|
+
contents = result["contents"]
|
|
12
|
+
return handle_empty unless contents
|
|
13
|
+
|
|
14
|
+
text = extract_hover_text(contents)
|
|
15
|
+
return handle_empty unless text && !text.empty?
|
|
16
|
+
|
|
17
|
+
# Display hover information in echo area or popup
|
|
18
|
+
display_hover(text)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def handle_empty
|
|
22
|
+
@editor.message = "No hover information"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def extract_hover_text(contents)
|
|
28
|
+
case contents
|
|
29
|
+
when String
|
|
30
|
+
contents
|
|
31
|
+
when Hash
|
|
32
|
+
markup_to_text(contents)
|
|
33
|
+
when Array
|
|
34
|
+
contents.map { |c| extract_hover_text(c) }.compact.join("\n\n")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def display_hover(text)
|
|
39
|
+
# Use floating window if available
|
|
40
|
+
if @editor.respond_to?(:show_floating)
|
|
41
|
+
@editor.show_floating(text, max_height: 15)
|
|
42
|
+
else
|
|
43
|
+
# Fallback to echo area display
|
|
44
|
+
lines = text.lines.map(&:chomp)
|
|
45
|
+
@editor.message = if lines.length > 1
|
|
46
|
+
"#{lines.first} (#{lines.length - 1} more lines)"
|
|
47
|
+
else
|
|
48
|
+
lines.first || text
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
module Lsp
|
|
5
|
+
module Handlers
|
|
6
|
+
# Handler for textDocument/references responses
|
|
7
|
+
class References < Base
|
|
8
|
+
protected
|
|
9
|
+
|
|
10
|
+
def handle_result(result)
|
|
11
|
+
return handle_empty unless result.is_a?(Array) && !result.empty?
|
|
12
|
+
|
|
13
|
+
locations = result.map { |loc| Protocol::Location.from_hash(loc) }
|
|
14
|
+
show_references(locations)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def handle_empty
|
|
18
|
+
@editor.message = "No references found"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def show_references(locations)
|
|
24
|
+
count = locations.length
|
|
25
|
+
|
|
26
|
+
# Build message with first few references
|
|
27
|
+
lines = ["Found #{count} reference#{"s" unless count == 1}"]
|
|
28
|
+
|
|
29
|
+
# Group by file for display
|
|
30
|
+
by_file = locations.group_by(&:file_path)
|
|
31
|
+
|
|
32
|
+
# Display first few references
|
|
33
|
+
displayed = 0
|
|
34
|
+
max_display = 3
|
|
35
|
+
|
|
36
|
+
by_file.each do |file_path, file_locations|
|
|
37
|
+
break if displayed >= max_display
|
|
38
|
+
|
|
39
|
+
file_locations.each do |loc|
|
|
40
|
+
break if displayed >= max_display
|
|
41
|
+
|
|
42
|
+
line = loc.range.start.line + 1
|
|
43
|
+
lines << " #{file_path || loc.uri}:#{line}"
|
|
44
|
+
displayed += 1
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
lines << " ... and #{count - max_display} more" if count > max_display
|
|
49
|
+
|
|
50
|
+
@editor.message = lines.first
|
|
51
|
+
|
|
52
|
+
# TODO: Integrate with quickfix list when available
|
|
53
|
+
# Store references for navigation
|
|
54
|
+
store_references(locations)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def store_references(locations)
|
|
58
|
+
# Store references for potential :cnext/:cprev navigation
|
|
59
|
+
# This could be integrated with Mui's quickfix system if available
|
|
60
|
+
@editor.instance_variable_set(:@lsp_references, locations)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "handlers/base"
|
|
4
|
+
require_relative "handlers/hover"
|
|
5
|
+
require_relative "handlers/definition"
|
|
6
|
+
require_relative "handlers/references"
|
|
7
|
+
require_relative "handlers/diagnostics"
|
|
8
|
+
require_relative "handlers/completion"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mui
|
|
4
|
+
module Lsp
|
|
5
|
+
module Highlighters
|
|
6
|
+
# Highlighter for LSP diagnostics (errors, warnings, etc.)
|
|
7
|
+
class DiagnosticHighlighter < Mui::Highlighters::Base
|
|
8
|
+
PRIORITY_DIAGNOSTICS = 250 # Between selection and search
|
|
9
|
+
|
|
10
|
+
def initialize(color_scheme, diagnostics)
|
|
11
|
+
super(color_scheme)
|
|
12
|
+
@diagnostics = diagnostics
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def highlights_for(row, line, _options = {})
|
|
16
|
+
highlights = []
|
|
17
|
+
|
|
18
|
+
@diagnostics.each do |d|
|
|
19
|
+
next unless row.between?(d.range.start.line, d.range.end.line)
|
|
20
|
+
|
|
21
|
+
start_col = d.range.start.line == row ? d.range.start.character : 0
|
|
22
|
+
end_col = if d.range.end.line == row
|
|
23
|
+
d.range.end.character
|
|
24
|
+
else
|
|
25
|
+
line.length
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Ensure end_col is at least start_col + 1
|
|
29
|
+
end_col = [end_col, start_col + 1].max
|
|
30
|
+
|
|
31
|
+
style = severity_style(d.severity)
|
|
32
|
+
highlights << Mui::Highlight.new(
|
|
33
|
+
start_col: start_col,
|
|
34
|
+
end_col: end_col,
|
|
35
|
+
style: style,
|
|
36
|
+
priority: priority
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
highlights
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def priority
|
|
44
|
+
PRIORITY_DIAGNOSTICS
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Update diagnostics (called when new diagnostics arrive)
|
|
48
|
+
def update(diagnostics)
|
|
49
|
+
@diagnostics = diagnostics
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def severity_style(severity)
|
|
55
|
+
case severity
|
|
56
|
+
when Protocol::DiagnosticSeverity::ERROR
|
|
57
|
+
:diagnostic_error
|
|
58
|
+
when Protocol::DiagnosticSeverity::WARNING
|
|
59
|
+
:diagnostic_warning
|
|
60
|
+
when Protocol::DiagnosticSeverity::INFORMATION
|
|
61
|
+
:diagnostic_info
|
|
62
|
+
when Protocol::DiagnosticSeverity::HINT
|
|
63
|
+
:diagnostic_hint
|
|
64
|
+
else
|
|
65
|
+
:diagnostic_error
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|