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.
@@ -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