collie-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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/.collie.yml.example +144 -0
  3. data/.rspec +3 -0
  4. data/CHANGELOG.md +10 -0
  5. data/LICENSE +21 -0
  6. data/README.md +273 -0
  7. data/Rakefile +8 -0
  8. data/collie-lsp.gemspec +36 -0
  9. data/exe/collie-lsp +9 -0
  10. data/lib/collie_lsp/collie_wrapper.rb +97 -0
  11. data/lib/collie_lsp/document_store.rb +66 -0
  12. data/lib/collie_lsp/handlers/code_action.rb +77 -0
  13. data/lib/collie_lsp/handlers/completion.rb +72 -0
  14. data/lib/collie_lsp/handlers/definition.rb +117 -0
  15. data/lib/collie_lsp/handlers/diagnostics.rb +64 -0
  16. data/lib/collie_lsp/handlers/document_symbol.rb +185 -0
  17. data/lib/collie_lsp/handlers/folding_range.rb +211 -0
  18. data/lib/collie_lsp/handlers/formatting.rb +55 -0
  19. data/lib/collie_lsp/handlers/hover.rb +104 -0
  20. data/lib/collie_lsp/handlers/references.rb +185 -0
  21. data/lib/collie_lsp/handlers/rename.rb +226 -0
  22. data/lib/collie_lsp/handlers/semantic_tokens.rb +302 -0
  23. data/lib/collie_lsp/handlers/workspace_symbol.rb +161 -0
  24. data/lib/collie_lsp/protocol/initialize.rb +58 -0
  25. data/lib/collie_lsp/protocol/shutdown.rb +25 -0
  26. data/lib/collie_lsp/protocol/text_document.rb +81 -0
  27. data/lib/collie_lsp/server.rb +104 -0
  28. data/lib/collie_lsp/version.rb +5 -0
  29. data/lib/collie_lsp.rb +25 -0
  30. data/vscode-extension/.gitignore +3 -0
  31. data/vscode-extension/.vscode/launch.json +17 -0
  32. data/vscode-extension/.vscode/tasks.json +14 -0
  33. data/vscode-extension/README.md +35 -0
  34. data/vscode-extension/package.json +49 -0
  35. data/vscode-extension/src/extension.ts +48 -0
  36. data/vscode-extension/test-grammar.y +42 -0
  37. data/vscode-extension/tsconfig.json +12 -0
  38. metadata +98 -0
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollieLsp
4
+ module Handlers
5
+ # Code folding support
6
+ module FoldingRange
7
+ module_function
8
+
9
+ # Handle textDocument/foldingRange request
10
+ # @param request [Hash] LSP request
11
+ # @param document_store [DocumentStore] Document store
12
+ # @param _collie [CollieWrapper] Collie wrapper (unused)
13
+ # @param writer [Object] Response writer
14
+ def handle(request, document_store, _collie, writer)
15
+ uri = request[:params][:textDocument][:uri]
16
+ doc = document_store.get(uri)
17
+
18
+ unless doc
19
+ writer.write(id: request[:id], result: [])
20
+ return
21
+ end
22
+
23
+ ranges = build_folding_ranges(doc[:text], doc[:ast])
24
+
25
+ writer.write(
26
+ id: request[:id],
27
+ result: ranges
28
+ )
29
+ end
30
+
31
+ # Build folding ranges from document
32
+ # @param text [String] Document text
33
+ # @param ast [Hash, nil] Parsed AST (may be nil)
34
+ # @return [Array<Hash>] LSP folding ranges
35
+ def build_folding_ranges(text, ast)
36
+ ranges = []
37
+
38
+ # Add ranges from AST if available
39
+ ranges.concat(build_ast_folding_ranges(ast)) if ast
40
+
41
+ # Add ranges from text structure
42
+ ranges.concat(build_text_folding_ranges(text))
43
+
44
+ # Sort and remove overlaps
45
+ ranges.sort_by { |r| [r[:startLine], r[:endLine]] }
46
+ end
47
+
48
+ # Build folding ranges from AST
49
+ # @param ast [Hash] Parsed AST
50
+ # @return [Array<Hash>] Folding ranges
51
+ def build_ast_folding_ranges(ast)
52
+ ranges = []
53
+
54
+ # Fold grammar rules with multiple alternatives
55
+ ast[:rules]&.each do |rule|
56
+ next unless rule[:location] && rule[:alternatives]
57
+ next if rule[:alternatives].size < 2
58
+
59
+ # Find the end of the rule (look for semicolon)
60
+ start_line = rule[:location][:line] - 1
61
+ end_line = find_rule_end_line(rule, ast)
62
+
63
+ # Only create a range if the rule spans multiple lines
64
+ ranges << create_folding_range(start_line, end_line, 'region') if end_line && end_line > start_line
65
+ end
66
+
67
+ ranges
68
+ end
69
+
70
+ # Build folding ranges from text structure
71
+ # @param text [String] Document text
72
+ # @return [Array<Hash>] Folding ranges
73
+ def build_text_folding_ranges(text)
74
+ ranges = []
75
+ lines = text.lines
76
+
77
+ # Fold block comments
78
+ ranges.concat(find_comment_blocks(lines))
79
+
80
+ # Fold C code blocks (%{ ... %})
81
+ ranges.concat(find_c_code_blocks(lines))
82
+
83
+ # Fold action blocks ({ ... })
84
+ ranges.concat(find_action_blocks(lines))
85
+
86
+ ranges
87
+ end
88
+
89
+ # Find the end line of a rule
90
+ # @param rule [Hash] Rule
91
+ # @param ast [Hash] AST (for context)
92
+ # @return [Integer, nil] End line number or nil
93
+ def find_rule_end_line(rule, ast)
94
+ # Find the next rule's start line
95
+ rule_index = ast[:rules].index(rule)
96
+ return nil unless rule_index
97
+
98
+ if rule_index < ast[:rules].size - 1
99
+ next_rule = ast[:rules][rule_index + 1]
100
+ return next_rule[:location][:line] - 2 if next_rule[:location]
101
+ end
102
+
103
+ # Last rule - use a default offset
104
+ rule[:location][:line] + 10
105
+ end
106
+
107
+ # Find block comment ranges
108
+ # @param lines [Array<String>] Document lines
109
+ # @return [Array<Hash>] Folding ranges
110
+ def find_comment_blocks(lines)
111
+ ranges = []
112
+ in_comment = false
113
+ comment_start = nil
114
+
115
+ lines.each_with_index do |line, idx|
116
+ if !in_comment && line.include?('/*')
117
+ in_comment = true
118
+ comment_start = idx
119
+ elsif in_comment && line.include?('*/')
120
+ in_comment = false
121
+ ranges << create_folding_range(comment_start, idx, 'comment') if comment_start && idx > comment_start
122
+ comment_start = nil
123
+ end
124
+ end
125
+
126
+ ranges
127
+ end
128
+
129
+ # Find C code block ranges
130
+ # @param lines [Array<String>] Document lines
131
+ # @return [Array<Hash>] Folding ranges
132
+ def find_c_code_blocks(lines)
133
+ ranges = []
134
+ in_block = false
135
+ block_start = nil
136
+
137
+ lines.each_with_index do |line, idx|
138
+ if !in_block && line.strip == '%{'
139
+ in_block = true
140
+ block_start = idx
141
+ elsif in_block && line.strip == '%}'
142
+ in_block = false
143
+ ranges << create_folding_range(block_start, idx, 'region') if block_start && idx > block_start
144
+ block_start = nil
145
+ end
146
+ end
147
+
148
+ ranges
149
+ end
150
+
151
+ # Find action block ranges (multi-line only)
152
+ # @param lines [Array<String>] Document lines
153
+ # @return [Array<Hash>] Folding ranges
154
+ def find_action_blocks(lines)
155
+ ranges = []
156
+
157
+ lines.each_with_index do |line, idx|
158
+ # Look for opening brace
159
+ brace_pos = line.index('{')
160
+ next unless brace_pos
161
+
162
+ # Find matching closing brace
163
+ end_line = find_matching_brace(lines, idx, brace_pos)
164
+ next unless end_line && end_line > idx
165
+
166
+ ranges << create_folding_range(idx, end_line, 'region')
167
+ end
168
+
169
+ ranges
170
+ end
171
+
172
+ # Find matching closing brace
173
+ # @param lines [Array<String>] Document lines
174
+ # @param start_line [Integer] Line with opening brace
175
+ # @param start_pos [Integer] Position of opening brace
176
+ # @return [Integer, nil] Line with closing brace or nil
177
+ def find_matching_brace(lines, start_line, start_pos)
178
+ depth = 1
179
+ pos = start_pos + 1
180
+
181
+ (start_line...lines.length).each do |line_idx|
182
+ line = lines[line_idx]
183
+ start = line_idx == start_line ? pos : 0
184
+
185
+ (start...line.length).each do |char_idx|
186
+ char = line[char_idx]
187
+ depth += 1 if char == '{'
188
+ depth -= 1 if char == '}'
189
+
190
+ return line_idx if depth.zero?
191
+ end
192
+ end
193
+
194
+ nil
195
+ end
196
+
197
+ # Create a folding range
198
+ # @param start_line [Integer] Start line (0-indexed)
199
+ # @param end_line [Integer] End line (0-indexed)
200
+ # @param kind [String] Folding range kind
201
+ # @return [Hash] LSP folding range
202
+ def create_folding_range(start_line, end_line, kind)
203
+ {
204
+ startLine: start_line,
205
+ endLine: end_line,
206
+ kind: kind
207
+ }
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollieLsp
4
+ module Handlers
5
+ # Document formatting support
6
+ module Formatting
7
+ module_function
8
+
9
+ # Handle textDocument/formatting request
10
+ # @param request [Hash] LSP request
11
+ # @param document_store [DocumentStore] Document store
12
+ # @param collie [CollieWrapper] Collie wrapper
13
+ # @param writer [Object] Response writer
14
+ def handle(request, document_store, collie, writer)
15
+ uri = request[:params][:textDocument][:uri]
16
+ doc = document_store.get(uri)
17
+
18
+ unless doc
19
+ writer.write(id: request[:id], result: nil)
20
+ return
21
+ end
22
+
23
+ filename = uri.gsub(%r{^file://}, '')
24
+ formatted = collie.format(doc[:text], filename: filename)
25
+
26
+ unless formatted
27
+ writer.write(id: request[:id], result: nil)
28
+ return
29
+ end
30
+
31
+ # Calculate text edits (replace entire document)
32
+ edits = [{
33
+ range: full_document_range(doc[:text]),
34
+ newText: formatted
35
+ }]
36
+
37
+ writer.write(
38
+ id: request[:id],
39
+ result: edits
40
+ )
41
+ end
42
+
43
+ # Get the range covering the entire document
44
+ # @param text [String] Document text
45
+ # @return [Hash] LSP range
46
+ def full_document_range(text)
47
+ lines = text.lines.count
48
+ {
49
+ start: { line: 0, character: 0 },
50
+ end: { line: lines, character: 0 }
51
+ }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollieLsp
4
+ module Handlers
5
+ # Show information on hover
6
+ module Hover
7
+ module_function
8
+
9
+ # Handle textDocument/hover request
10
+ # @param request [Hash] LSP request
11
+ # @param document_store [DocumentStore] Document store
12
+ # @param _collie [CollieWrapper] Collie wrapper (unused)
13
+ # @param writer [Object] Response writer
14
+ def handle(request, document_store, _collie, writer)
15
+ uri = request[:params][:textDocument][:uri]
16
+ position = request[:params][:position]
17
+ doc = document_store.get(uri)
18
+
19
+ unless doc
20
+ writer.write(id: request[:id], result: nil)
21
+ return
22
+ end
23
+
24
+ ast = doc[:ast]
25
+ unless ast
26
+ writer.write(id: request[:id], result: nil)
27
+ return
28
+ end
29
+
30
+ # Find symbol at position
31
+ symbol = find_symbol_at_position(doc[:text], position)
32
+ unless symbol
33
+ writer.write(id: request[:id], result: nil)
34
+ return
35
+ end
36
+
37
+ hover_content = build_hover_content(ast, symbol)
38
+
39
+ if hover_content
40
+ writer.write(
41
+ id: request[:id],
42
+ result: {
43
+ contents: hover_content
44
+ }
45
+ )
46
+ else
47
+ writer.write(id: request[:id], result: nil)
48
+ end
49
+ end
50
+
51
+ # Find symbol at the given position
52
+ # @param text [String] Document text
53
+ # @param position [Hash] LSP position
54
+ # @return [String, nil] Symbol name or nil
55
+ def find_symbol_at_position(text, position)
56
+ lines = text.lines
57
+ line = lines[position[:line]]
58
+ return nil unless line
59
+
60
+ # Extract word at character position
61
+ char = position[:character]
62
+ start_pos = char
63
+ end_pos = char
64
+
65
+ # Move backwards to find word start
66
+ start_pos -= 1 while start_pos.positive? && line[start_pos - 1] =~ /[A-Za-z0-9_]/
67
+ # Move forwards to find word end
68
+ end_pos += 1 while end_pos < line.length && line[end_pos] =~ /[A-Za-z0-9_]/
69
+
70
+ line[start_pos...end_pos]
71
+ end
72
+
73
+ # Build hover content for a symbol
74
+ # @param ast [Hash] Parsed AST
75
+ # @param symbol [String] Symbol name
76
+ # @return [Hash, nil] LSP markup content or nil
77
+ def build_hover_content(ast, symbol)
78
+ # Check if it's a token
79
+ ast[:declarations]&.each do |decl|
80
+ next unless decl[:kind] == :token
81
+
82
+ if decl[:names]&.include?(symbol)
83
+ return {
84
+ kind: 'markdown',
85
+ value: "**Token**: `#{symbol}`\n\nType: `#{decl[:type_tag] || 'none'}`"
86
+ }
87
+ end
88
+ end
89
+
90
+ # Check if it's a nonterminal
91
+ rule = ast[:rules]&.find { |r| r[:name] == symbol }
92
+ if rule
93
+ alt_count = rule[:alternatives]&.size || 0
94
+ return {
95
+ kind: 'markdown',
96
+ value: "**Nonterminal**: `#{symbol}`\n\n#{alt_count} alternative(s)"
97
+ }
98
+ end
99
+
100
+ nil
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollieLsp
4
+ module Handlers
5
+ # Find all references to a symbol
6
+ module References
7
+ module_function
8
+
9
+ # Handle textDocument/references request
10
+ # @param request [Hash] LSP request
11
+ # @param document_store [DocumentStore] Document store
12
+ # @param _collie [CollieWrapper] Collie wrapper (unused)
13
+ # @param writer [Object] Response writer
14
+ def handle(request, document_store, _collie, writer)
15
+ uri = request[:params][:textDocument][:uri]
16
+ position = request[:params][:position]
17
+ include_declaration = request[:params][:context][:includeDeclaration]
18
+ doc = document_store.get(uri)
19
+
20
+ unless doc
21
+ writer.write(id: request[:id], result: [])
22
+ return
23
+ end
24
+
25
+ ast = doc[:ast]
26
+ unless ast
27
+ writer.write(id: request[:id], result: [])
28
+ return
29
+ end
30
+
31
+ # Find symbol at position
32
+ symbol = find_symbol_at_position(doc[:text], position)
33
+ unless symbol
34
+ writer.write(id: request[:id], result: [])
35
+ return
36
+ end
37
+
38
+ locations = find_references(ast, symbol, uri, doc[:text], include_declaration)
39
+
40
+ writer.write(
41
+ id: request[:id],
42
+ result: locations
43
+ )
44
+ end
45
+
46
+ # Find symbol at the given position
47
+ # @param text [String] Document text
48
+ # @param position [Hash] LSP position
49
+ # @return [String, nil] Symbol name or nil
50
+ def find_symbol_at_position(text, position)
51
+ lines = text.lines
52
+ line = lines[position[:line]]
53
+ return nil unless line
54
+
55
+ # Extract word at character position
56
+ char = position[:character]
57
+ start_pos = char
58
+ end_pos = char
59
+
60
+ # Move backwards to find word start
61
+ start_pos -= 1 while start_pos.positive? && line[start_pos - 1] =~ /[A-Za-z0-9_]/
62
+ # Move forwards to find word end
63
+ end_pos += 1 while end_pos < line.length && line[end_pos] =~ /[A-Za-z0-9_]/
64
+
65
+ line[start_pos...end_pos]
66
+ end
67
+
68
+ # Find all references to a symbol
69
+ # @param ast [Hash] Parsed AST
70
+ # @param symbol [String] Symbol name
71
+ # @param uri [String] Document URI
72
+ # @param text [String] Document text
73
+ # @param include_declaration [Boolean] Include declaration in results
74
+ # @return [Array<Hash>] LSP locations
75
+ def find_references(ast, symbol, uri, text, include_declaration)
76
+ locations = []
77
+
78
+ # Find declaration location
79
+ declaration_loc = find_declaration(ast, symbol)
80
+
81
+ # Add declaration if requested
82
+ locations << create_location(uri, declaration_loc, symbol) if include_declaration && declaration_loc
83
+
84
+ # Find all usages in rules
85
+ locations.concat(find_usage_in_rules(ast, symbol, uri, text))
86
+
87
+ locations
88
+ end
89
+
90
+ # Find symbol usage in grammar rules
91
+ # @param ast [Hash] Parsed AST
92
+ # @param symbol [String] Symbol name
93
+ # @param uri [String] Document URI
94
+ # @param text [String] Document text
95
+ # @return [Array<Hash>] LSP locations
96
+ def find_usage_in_rules(ast, symbol, uri, text)
97
+ locations = []
98
+
99
+ ast[:rules]&.each do |rule|
100
+ rule[:alternatives]&.each_with_index do |alt, alt_index|
101
+ alt[:symbols]&.each_with_index do |sym, sym_index|
102
+ next unless sym[:name] == symbol
103
+
104
+ loc = estimate_symbol_location(text, rule, alt_index, sym_index, symbol)
105
+ locations << create_location(uri, loc, symbol) if loc
106
+ end
107
+ end
108
+ end
109
+
110
+ locations
111
+ end
112
+
113
+ # Find declaration location for a symbol
114
+ # @param ast [Hash] Parsed AST
115
+ # @param symbol [String] Symbol name
116
+ # @return [Hash, nil] Location hash or nil
117
+ def find_declaration(ast, symbol)
118
+ # Check token declarations
119
+ ast[:declarations]&.each do |decl|
120
+ next unless decl[:kind] == :token
121
+
122
+ return decl[:location] if decl[:names]&.include?(symbol) && decl[:location]
123
+ end
124
+
125
+ # Check nonterminal rules
126
+ rule = ast[:rules]&.find { |r| r[:name] == symbol }
127
+ return rule[:location] if rule && rule[:location]
128
+
129
+ nil
130
+ end
131
+
132
+ # Estimate symbol location in text
133
+ # @param text [String] Document text
134
+ # @param rule [Hash] Rule containing the symbol
135
+ # @param _alt_index [Integer] Alternative index (unused)
136
+ # @param _sym_index [Integer] Symbol index (unused)
137
+ # @param symbol [String] Symbol name
138
+ # @return [Hash, nil] Location hash or nil
139
+ def estimate_symbol_location(text, rule, _alt_index, _sym_index, symbol)
140
+ # This is a simplified implementation that searches for the symbol
141
+ # In a real implementation, positions would be tracked during parsing
142
+ return nil unless rule[:location]
143
+
144
+ search_symbol_from_line(text.lines, rule[:location][:line] - 1, symbol)
145
+ end
146
+
147
+ # Search for symbol starting from a specific line
148
+ # @param lines [Array<String>] Document lines
149
+ # @param start_line [Integer] Starting line number
150
+ # @param symbol [String] Symbol to search for
151
+ # @return [Hash, nil] Location hash or nil
152
+ def search_symbol_from_line(lines, start_line, symbol)
153
+ lines[start_line..].each_with_index do |line, offset|
154
+ col = line.index(symbol)
155
+ next unless col
156
+
157
+ return {
158
+ line: start_line + offset + 1,
159
+ column: col + 1
160
+ }
161
+ end
162
+
163
+ nil
164
+ end
165
+
166
+ # Create LSP location from position
167
+ # @param uri [String] Document URI
168
+ # @param location [Hash] Location hash with :line and :column
169
+ # @param symbol [String] Symbol name
170
+ # @return [Hash] LSP location
171
+ def create_location(uri, location, symbol)
172
+ line = location[:line] - 1
173
+ column = location[:column] - 1
174
+
175
+ {
176
+ uri: uri,
177
+ range: {
178
+ start: { line: line, character: column },
179
+ end: { line: line, character: column + symbol.length }
180
+ }
181
+ }
182
+ end
183
+ end
184
+ end
185
+ end