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,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollieLsp
4
+ module Handlers
5
+ # Quick fixes for autocorrectable offenses
6
+ module CodeAction
7
+ module_function
8
+
9
+ # Handle textDocument/codeAction 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
+ range = request[:params][:range]
17
+ doc = document_store.get(uri)
18
+
19
+ unless doc
20
+ writer.write(id: request[:id], result: [])
21
+ return
22
+ end
23
+
24
+ # Get diagnostics in range
25
+ diagnostics = doc[:diagnostics].select do |diag|
26
+ in_range?(diag, range)
27
+ end
28
+
29
+ code_actions = []
30
+
31
+ # Add "Fix all" action if there are any diagnostics
32
+ if diagnostics.any?
33
+ filename = uri.gsub(%r{^file://}, '')
34
+ corrected = collie.autocorrect(doc[:text], filename: filename)
35
+
36
+ code_actions << {
37
+ title: 'Fix all auto-correctable offenses',
38
+ kind: 'source.fixAll',
39
+ edit: {
40
+ changes: {
41
+ uri => [{
42
+ range: full_document_range(doc[:text]),
43
+ newText: corrected
44
+ }]
45
+ }
46
+ }
47
+ }
48
+ end
49
+
50
+ writer.write(
51
+ id: request[:id],
52
+ result: code_actions
53
+ )
54
+ end
55
+
56
+ # Check if a diagnostic is within the given range
57
+ # @param diagnostic [Hash] LSP diagnostic
58
+ # @param range [Hash] LSP range
59
+ # @return [Boolean]
60
+ def in_range?(diagnostic, range)
61
+ diagnostic[:range][:start][:line] >= range[:start][:line] &&
62
+ diagnostic[:range][:end][:line] <= range[:end][:line]
63
+ end
64
+
65
+ # Get the range covering the entire document
66
+ # @param text [String] Document text
67
+ # @return [Hash] LSP range
68
+ def full_document_range(text)
69
+ lines = text.lines.count
70
+ {
71
+ start: { line: 0, character: 0 },
72
+ end: { line: lines, character: 0 }
73
+ }
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollieLsp
4
+ module Handlers
5
+ # Auto-completion for tokens and nonterminals
6
+ module Completion
7
+ module_function
8
+
9
+ # Handle textDocument/completion 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: [])
21
+ return
22
+ end
23
+
24
+ ast = doc[:ast]
25
+ unless ast
26
+ writer.write(id: request[:id], result: [])
27
+ return
28
+ end
29
+
30
+ completions = build_completions(ast)
31
+
32
+ writer.write(
33
+ id: request[:id],
34
+ result: completions
35
+ )
36
+ end
37
+
38
+ # Build completion items from AST
39
+ # @param ast [Hash] Parsed AST
40
+ # @return [Array<Hash>] LSP completion items
41
+ def build_completions(ast)
42
+ completions = []
43
+
44
+ # Add all declared tokens
45
+ ast[:declarations]&.each do |decl|
46
+ next unless decl[:kind] == :token
47
+
48
+ decl[:names]&.each do |name|
49
+ completions << {
50
+ label: name,
51
+ kind: 14, # Keyword
52
+ detail: "Token: #{name}",
53
+ documentation: 'Declared token'
54
+ }
55
+ end
56
+ end
57
+
58
+ # Add all nonterminals
59
+ ast[:rules]&.each do |rule|
60
+ completions << {
61
+ label: rule[:name],
62
+ kind: 7, # Class (nonterminal)
63
+ detail: "Nonterminal: #{rule[:name]}",
64
+ documentation: 'Grammar rule'
65
+ }
66
+ end
67
+
68
+ completions
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollieLsp
4
+ module Handlers
5
+ # Go to definition support
6
+ module Definition
7
+ module_function
8
+
9
+ # Handle textDocument/definition 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
+ location = find_definition_location(ast, symbol, uri)
38
+
39
+ if location
40
+ writer.write(id: request[:id], result: location)
41
+ else
42
+ writer.write(id: request[:id], result: nil)
43
+ end
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 definition location for a symbol
69
+ # @param ast [Hash] Parsed AST
70
+ # @param symbol [String] Symbol name
71
+ # @param uri [String] Document URI
72
+ # @return [Hash, nil] LSP location or nil
73
+ def find_definition_location(ast, symbol, uri)
74
+ # Check if it's a token declaration
75
+ ast[:declarations]&.each do |decl|
76
+ next unless decl[:kind] == :token
77
+
78
+ if decl[:names]&.include?(symbol) && decl[:location]
79
+ return {
80
+ uri: uri,
81
+ range: {
82
+ start: {
83
+ line: decl[:location][:line] - 1,
84
+ character: decl[:location][:column] - 1
85
+ },
86
+ end: {
87
+ line: decl[:location][:line] - 1,
88
+ character: decl[:location][:column] + symbol.length - 1
89
+ }
90
+ }
91
+ }
92
+ end
93
+ end
94
+
95
+ # Check if it's a nonterminal rule
96
+ rule = ast[:rules]&.find { |r| r[:name] == symbol }
97
+ if rule && rule[:location]
98
+ return {
99
+ uri: uri,
100
+ range: {
101
+ start: {
102
+ line: rule[:location][:line] - 1,
103
+ character: rule[:location][:column] - 1
104
+ },
105
+ end: {
106
+ line: rule[:location][:line] - 1,
107
+ character: rule[:location][:column] + symbol.length - 1
108
+ }
109
+ }
110
+ }
111
+ end
112
+
113
+ nil
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollieLsp
4
+ module Handlers
5
+ # Converts Collie offenses to LSP diagnostics
6
+ module Diagnostics
7
+ module_function
8
+
9
+ # Publish diagnostics for a document
10
+ # @param uri [String] Document URI
11
+ # @param offenses [Array<Hash>] Collie offenses
12
+ # @param document_store [DocumentStore] Document store
13
+ # @param writer [Object] Response writer
14
+ def publish(uri, offenses, document_store, writer)
15
+ diagnostics = offenses.map do |offense|
16
+ offense_to_diagnostic(offense)
17
+ end
18
+
19
+ document_store.update_diagnostics(uri, diagnostics)
20
+
21
+ writer.write(
22
+ method: 'textDocument/publishDiagnostics',
23
+ params: {
24
+ uri: uri,
25
+ diagnostics: diagnostics
26
+ }
27
+ )
28
+ end
29
+
30
+ # Convert a Collie offense to an LSP diagnostic
31
+ # @param offense [Hash] Collie offense
32
+ # @return [Hash] LSP diagnostic
33
+ def offense_to_diagnostic(offense)
34
+ location = offense[:location] || { line: 1, column: 1 }
35
+ line = location[:line] - 1 # LSP is 0-indexed
36
+ column = location[:column] - 1
37
+
38
+ {
39
+ range: {
40
+ start: { line: line, character: column },
41
+ end: { line: line, character: column + 10 } # Approximate
42
+ },
43
+ severity: severity_to_lsp(offense[:severity]),
44
+ code: offense[:rule_name] || 'unknown',
45
+ source: 'collie',
46
+ message: offense[:message] || 'Unknown error'
47
+ }
48
+ end
49
+
50
+ # Convert Collie severity to LSP severity
51
+ # @param severity [Symbol] Collie severity (:error, :warning, :convention, :info)
52
+ # @return [Integer] LSP severity (1-4)
53
+ def severity_to_lsp(severity)
54
+ case severity
55
+ when :error then 1 # Error
56
+ when :warning then 2 # Warning
57
+ when :convention then 3 # Information
58
+ when :info then 4 # Hint
59
+ else 3
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollieLsp
4
+ module Handlers
5
+ # Document symbol support for outline view
6
+ module DocumentSymbol
7
+ module_function
8
+
9
+ # Handle textDocument/documentSymbol 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
+ ast = doc[:ast]
24
+ unless ast
25
+ writer.write(id: request[:id], result: [])
26
+ return
27
+ end
28
+
29
+ symbols = build_document_symbols(ast)
30
+
31
+ writer.write(
32
+ id: request[:id],
33
+ result: symbols
34
+ )
35
+ end
36
+
37
+ # Build document symbols from AST
38
+ # @param ast [Hash] Parsed AST
39
+ # @return [Array<Hash>] LSP document symbols
40
+ def build_document_symbols(ast)
41
+ symbols = []
42
+
43
+ symbols.concat(build_token_symbols(ast))
44
+ symbols.concat(build_type_symbols(ast))
45
+ symbols.concat(build_precedence_symbols(ast))
46
+ symbols.concat(build_rule_symbols(ast))
47
+
48
+ symbols
49
+ end
50
+
51
+ # Build token symbols
52
+ # @param ast [Hash] Parsed AST
53
+ # @return [Array<Hash>] Token symbols
54
+ def build_token_symbols(ast)
55
+ symbols = []
56
+ ast[:declarations]&.each do |decl|
57
+ next unless decl[:kind] == :token && decl[:location]
58
+
59
+ decl[:names]&.each do |name|
60
+ symbols << create_symbol(
61
+ name: name,
62
+ kind: 14,
63
+ location: decl[:location],
64
+ detail: 'Token'
65
+ )
66
+ end
67
+ end
68
+ symbols
69
+ end
70
+
71
+ # Build type symbols
72
+ # @param ast [Hash] Parsed AST
73
+ # @return [Array<Hash>] Type symbols
74
+ def build_type_symbols(ast)
75
+ symbols = []
76
+ ast[:declarations]&.each do |decl|
77
+ next unless decl[:kind] == :type && decl[:location]
78
+
79
+ decl[:names]&.each do |name|
80
+ symbols << create_symbol(
81
+ name: name,
82
+ kind: 7,
83
+ location: decl[:location],
84
+ detail: 'Type'
85
+ )
86
+ end
87
+ end
88
+ symbols
89
+ end
90
+
91
+ # Build precedence symbols
92
+ # @param ast [Hash] Parsed AST
93
+ # @return [Array<Hash>] Precedence symbols
94
+ def build_precedence_symbols(ast)
95
+ symbols = []
96
+ ast[:declarations]&.each do |decl|
97
+ next unless %i[left right nonassoc].include?(decl[:kind]) && decl[:location]
98
+
99
+ assoc_name = decl[:kind].to_s.capitalize
100
+ decl[:tokens]&.each do |token|
101
+ symbols << create_symbol(
102
+ name: token,
103
+ kind: 22,
104
+ location: decl[:location],
105
+ detail: "#{assoc_name} precedence"
106
+ )
107
+ end
108
+ end
109
+ symbols
110
+ end
111
+
112
+ # Build rule symbols
113
+ # @param ast [Hash] Parsed AST
114
+ # @return [Array<Hash>] Rule symbols
115
+ def build_rule_symbols(ast)
116
+ symbols = []
117
+ ast[:rules]&.each do |rule|
118
+ next unless rule[:location]
119
+
120
+ children = build_rule_children(rule)
121
+ symbols << create_symbol(
122
+ name: rule[:name],
123
+ kind: 12,
124
+ location: rule[:location],
125
+ detail: "Grammar rule (#{rule[:alternatives]&.size || 0} alternatives)",
126
+ children: children
127
+ )
128
+ end
129
+ symbols
130
+ end
131
+
132
+ # Build children symbols for a rule (alternatives)
133
+ # @param rule [Hash] Grammar rule
134
+ # @return [Array<Hash>] Child symbols
135
+ def build_rule_children(rule)
136
+ children = []
137
+
138
+ rule[:alternatives]&.each_with_index do |alt, index|
139
+ next unless alt[:location]
140
+
141
+ # Create a symbol for each alternative
142
+ symbols_str = alt[:symbols]&.map { |s| s[:name] }&.join(' ') || 'ε'
143
+ children << create_symbol(
144
+ name: "Alternative #{index + 1}",
145
+ kind: 6, # Property
146
+ location: alt[:location],
147
+ detail: symbols_str
148
+ )
149
+ end
150
+
151
+ children
152
+ end
153
+
154
+ # Create a document symbol
155
+ # @param name [String] Symbol name
156
+ # @param kind [Integer] LSP symbol kind
157
+ # @param location [Hash] Location hash with :line and :column
158
+ # @param detail [String] Symbol detail
159
+ # @param children [Array<Hash>] Child symbols
160
+ # @return [Hash] LSP document symbol
161
+ def create_symbol(name:, kind:, location:, detail: nil, children: nil)
162
+ line = location[:line] - 1
163
+ column = location[:column] - 1
164
+
165
+ symbol = {
166
+ name: name,
167
+ kind: kind,
168
+ range: {
169
+ start: { line: line, character: column },
170
+ end: { line: line, character: column + name.length }
171
+ },
172
+ selectionRange: {
173
+ start: { line: line, character: column },
174
+ end: { line: line, character: column + name.length }
175
+ }
176
+ }
177
+
178
+ symbol[:detail] = detail if detail
179
+ symbol[:children] = children if children && !children.empty?
180
+
181
+ symbol
182
+ end
183
+ end
184
+ end
185
+ end