Class: Synthra::LSP::Server
- Inherits:
-
Object
- Object
- Synthra::LSP::Server
- Defined in:
- lib/synthra/lsp/server.rb
Overview
Language Server Protocol server for Synthra
Implements LSP 3.17 specification for Synthra schema files. Provides IDE features like go-to-definition, hover, completion, and diagnostics.
Instance Method Summary collapse
-
#behavior_documentation(name) ⇒ String
private
Get documentation for a behavior.
-
#extract_word_at_position(line, col) ⇒ String
private
Extract word at cursor position.
-
#find_all_schemas(current_uri) ⇒ Array<String>
private
Find all schema names in workspace.
-
#find_field_definition(schema_uri, schema_name, field_name) ⇒ Hash?
private
Find field definition within a schema.
-
#find_schema_definition(schema_name, current_uri) ⇒ Array<String, Integer>?
private
Find schema definition in workspace.
-
#handle_completion(params) ⇒ Hash
private
Handle textDocument/completion request.
-
#handle_definition(params) ⇒ Hash?
private
Handle textDocument/definition request.
-
#handle_diagnostic(params) ⇒ Hash
private
Handle textDocument/diagnostic request.
-
#handle_did_change(params) ⇒ Object
private
Handle textDocument/didChange notification.
-
#handle_did_close(params) ⇒ Object
private
Handle textDocument/didClose notification.
-
#handle_did_open(params) ⇒ Object
private
Handle textDocument/didOpen notification.
-
#handle_hover(params) ⇒ Hash?
private
Handle textDocument/hover request.
-
#hover_behavior(name) ⇒ Hash?
private
Get hover information for a behavior.
-
#hover_schema(name) ⇒ Hash?
private
Get hover information for a schema.
-
#hover_type(name) ⇒ Hash?
private
Get hover information for a type.
-
#initialize(input: $stdin, output: $stdout) ⇒ Server
constructor
Initialize LSP server.
-
#initialize_response(params) ⇒ Hash
private
Initialize response.
-
#process_message(message) ⇒ Object
private
Process a JSON-RPC message.
-
#read_header ⇒ Hash?
private
Read HTTP-style header from input.
-
#run ⇒ Object
Run the LSP server (main loop).
-
#send_error(code, message, id: nil) ⇒ Object
private
Send JSON-RPC error.
-
#send_message(message) ⇒ Object
private
Send JSON-RPC message.
-
#send_notification(method, params) ⇒ Object
private
Send JSON-RPC notification.
-
#send_response(id, result) ⇒ Object
private
Send JSON-RPC response.
-
#type_documentation(name) ⇒ String
private
Get documentation for a type.
-
#validate_document(uri) ⇒ Array<Hash>
private
Validate a document and return diagnostics.
Constructor Details
#initialize(input: $stdin, output: $stdout) ⇒ Server
Initialize LSP server
43 44 45 46 47 48 49 |
# File 'lib/synthra/lsp/server.rb', line 43 def initialize(input: $stdin, output: $stdout) @input = input @output = output @registry = Registry.new @workspace_roots = [] @documents = {} # URI => { content: String, version: Integer } end |
Instance Method Details
#behavior_documentation(name) ⇒ String (private)
Get documentation for a behavior
582 583 584 |
# File 'lib/synthra/lsp/server.rb', line 582 def behavior_documentation(name) "Behavior: @#{name}" end |
#extract_word_at_position(line, col) ⇒ String (private)
Extract word at cursor position
522 523 524 525 526 527 528 529 530 |
# File 'lib/synthra/lsp/server.rb', line 522 def extract_word_at_position(line, col) # Find word boundaries start = col start -= 1 while start > 0 && line[start - 1] =~ /[\w@]/ finish = col finish += 1 while finish < line.length && line[finish] =~ /[\w]/ line[start...finish] || "" end |
#find_all_schemas(current_uri) ⇒ Array<String> (private)
Find all schema names in workspace
502 503 504 505 506 507 508 509 510 511 512 513 514 |
# File 'lib/synthra/lsp/server.rb', line 502 def find_all_schemas(current_uri) schemas = [] @documents.each do |uri, doc_data| doc_data[:content].lines.each do |line| if line =~ /^([A-Z]\w+):\s*$/ schemas << $1 unless schemas.include?($1) end end end schemas end |
#find_field_definition(schema_uri, schema_name, field_name) ⇒ Hash? (private)
Find field definition within a schema
470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 |
# File 'lib/synthra/lsp/server.rb', line 470 def find_field_definition(schema_uri, schema_name, field_name) doc = @documents[schema_uri] return nil unless doc in_schema = false doc[:content].lines.each_with_index do |line, idx| if line =~ /^#{Regexp.escape(schema_name)}:\s*$/ in_schema = true next end break if in_schema && line !~ /^\s/ # End of schema if in_schema && line =~ /^\s+#{Regexp.escape(field_name)}(\??):/ return { uri: schema_uri, range: { start: { line: idx, character: line.index(field_name) }, end: { line: idx, character: line.index(field_name) + field_name.length } } } end end nil end |
#find_schema_definition(schema_name, current_uri) ⇒ Array<String, Integer>? (private)
Find schema definition in workspace
438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 |
# File 'lib/synthra/lsp/server.rb', line 438 def find_schema_definition(schema_name, current_uri) # Check current document first doc = @documents[current_uri] if doc doc[:content].lines.each_with_index do |line, idx| if line =~ /^#{Regexp.escape(schema_name)}:\s*$/ return [current_uri, idx] end end end # Check other documents in workspace @documents.each do |uri, doc_data| next if uri == current_uri doc_data[:content].lines.each_with_index do |line, idx| if line =~ /^#{Regexp.escape(schema_name)}:\s*$/ return [uri, idx] end end end nil end |
#handle_completion(params) ⇒ Hash (private)
Handle textDocument/completion request
Returns completion suggestions for types, behaviors, and schemas.
281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 |
# File 'lib/synthra/lsp/server.rb', line 281 def handle_completion(params) uri = params.dig("textDocument", "uri") position = params["position"] return { items: [] } unless uri && position doc = @documents[uri] return { items: [] } unless doc line = doc[:content].lines[position["line"]] return { items: [] } unless line col = position["character"] prefix = line[0..col] items = [] # Behavior completions (@latency, @failure, etc.) if prefix =~ /@(\w*)$/ prefix_text = $1 Behaviors::Registry.names.each do |name| next unless name.to_s.start_with?(prefix_text) items << { label: "@#{name}", kind: 14, # Keyword documentation: behavior_documentation(name), insertText: "@#{name} " } end end # Type completions if prefix =~ /:\s*(\w*)$/ prefix_text = $1 Types::Registry.names.each do |name| next unless name.start_with?(prefix_text) items << { label: name, kind: 6, # Class documentation: type_documentation(name), insertText: name } end end # Schema completions (for Ref() or type references) if prefix =~ /(Ref\(|:\s*)([A-Z]\w*)$/ prefix_text = $2 find_all_schemas(uri).each do |schema_name| next unless schema_name.start_with?(prefix_text) items << { label: schema_name, kind: 5, # Class documentation: "Schema: #{schema_name}", insertText: schema_name } end end { items: items } end |
#handle_definition(params) ⇒ Hash? (private)
Handle textDocument/definition request
Returns location of definition for Ref(User.id) or schema references.
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 |
# File 'lib/synthra/lsp/server.rb', line 180 def handle_definition(params) uri = params.dig("textDocument", "uri") position = params["position"] return nil unless uri && position doc = @documents[uri] return nil unless doc line = doc[:content].lines[position["line"]] return nil unless line # Find schema reference or Ref() call at cursor position col = position["character"] # Check for Ref(Schema.field) pattern if line[0..col] =~ /Ref\(([A-Z]\w+)\.(\w+)\)/ schema_name = $1 field_name = $2 # Find schema definition in current or other files schema_uri, schema_pos = find_schema_definition(schema_name, uri) return nil unless schema_uri # Find field definition within schema field_pos = find_field_definition(schema_uri, schema_name, field_name) return field_pos if field_pos # Return schema definition if field not found { uri: schema_uri, range: { start: { line: schema_pos, character: 0 }, end: { line: schema_pos, character: schema_name.length } } } # Check for schema type reference (e.g., "address: Address") elsif line[0..col] =~ /:\s*([A-Z]\w+)(\?)?\s*$/ schema_name = $1 schema_uri, schema_pos = find_schema_definition(schema_name, uri) return nil unless schema_uri { uri: schema_uri, range: { start: { line: schema_pos, character: 0 }, end: { line: schema_pos, character: schema_name.length } } } else nil end end |
#handle_diagnostic(params) ⇒ Hash (private)
Handle textDocument/diagnostic request
Returns validation errors and warnings for the document.
352 353 354 355 356 357 358 359 360 361 |
# File 'lib/synthra/lsp/server.rb', line 352 def handle_diagnostic(params) uri = params.dig("textDocument", "uri") return { items: [] } unless uri doc = @documents[uri] return { items: [] } unless doc diagnostics = validate_document(uri) { items: diagnostics } end |
#handle_did_change(params) ⇒ Object (private)
Handle textDocument/didChange notification
149 150 151 152 153 154 155 156 157 158 159 160 161 162 |
# File 'lib/synthra/lsp/server.rb', line 149 def handle_did_change(params) uri = params.dig("textDocument", "uri") changes = params.dig("contentChanges") || [] version = params.dig("textDocument", "version") return unless uri && @documents[uri] # Apply changes (simplified - assumes full document replacement) if changes.any? @documents[uri][:content] = changes.last["text"] @documents[uri][:version] = version if version validate_document(uri) end end |
#handle_did_close(params) ⇒ Object (private)
Handle textDocument/didClose notification
168 169 170 171 |
# File 'lib/synthra/lsp/server.rb', line 168 def handle_did_close(params) uri = params.dig("textDocument", "uri") @documents.delete(uri) if uri end |
#handle_did_open(params) ⇒ Object (private)
Handle textDocument/didOpen notification
136 137 138 139 140 141 142 143 |
# File 'lib/synthra/lsp/server.rb', line 136 def handle_did_open(params) uri = params.dig("textDocument", "uri") text = params.dig("textDocument", "text") return unless uri && text @documents[uri] = { content: text, version: 0 } validate_document(uri) end |
#handle_hover(params) ⇒ Hash? (private)
Handle textDocument/hover request
Returns documentation for types, behaviors, or schemas at cursor.
240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 |
# File 'lib/synthra/lsp/server.rb', line 240 def handle_hover(params) uri = params.dig("textDocument", "uri") position = params["position"] return nil unless uri && position doc = @documents[uri] return nil unless doc line = doc[:content].lines[position["line"]] return nil unless line col = position["character"] word = extract_word_at_position(line, col) # Check for behavior annotation if word.start_with?("@") behavior_name = word[1..] return hover_behavior(behavior_name) end # Check for type name if Types::Registry.registered?(word) return hover_type(word) end # Check for schema name if word =~ /^[A-Z]/ schema_uri, _ = find_schema_definition(word, uri) return hover_schema(word) if schema_uri end nil end |
#hover_behavior(name) ⇒ Hash? (private)
Get hover information for a behavior
537 538 539 540 541 542 543 544 545 546 547 |
# File 'lib/synthra/lsp/server.rb', line 537 def hover_behavior(name) behavior_class = Behaviors::Registry.lookup(name.to_sym) rescue nil return nil unless behavior_class { contents: { kind: "markdown", value: "**@#{name}**\n\nBehavior: #{behavior_class.name}" } } end |
#hover_schema(name) ⇒ Hash? (private)
Get hover information for a schema
568 569 570 571 572 573 574 575 |
# File 'lib/synthra/lsp/server.rb', line 568 def hover_schema(name) { contents: { kind: "markdown", value: "**#{name}**\n\nSchema definition" } } end |
#hover_type(name) ⇒ Hash? (private)
Get hover information for a type
554 555 556 557 558 559 560 561 |
# File 'lib/synthra/lsp/server.rb', line 554 def hover_type(name) { contents: { kind: "markdown", value: "**#{name}**\n\nType: #{name}" } } end |
#initialize_response(params) ⇒ Hash (private)
Initialize response
600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 |
# File 'lib/synthra/lsp/server.rb', line 600 def initialize_response(params) @workspace_roots = [params.dig("workspaceFolders", 0, "uri")].compact { capabilities: { textDocumentSync: { openClose: true, change: 1, # Full willSave: false, willSaveWaitUntil: false, save: { includeText: false } }, hoverProvider: true, definitionProvider: true, completionProvider: { triggerCharacters: [":", "@", "(", "."] }, diagnosticProvider: { interFileDependencies: true, workspaceDiagnostics: false } }, serverInfo: { name: "Synthra LSP", version: Synthra::VERSION } } end |
#process_message(message) ⇒ Object (private)
Process a JSON-RPC message
98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 |
# File 'lib/synthra/lsp/server.rb', line 98 def () id = ["id"] method = ["method"] params = ["params"] || {} case method when "initialize" send_response(id, initialize_response(params)) when "initialized" # Client is ready when "textDocument/didOpen" handle_did_open(params) when "textDocument/didChange" handle_did_change(params) when "textDocument/didClose" handle_did_close(params) when "textDocument/definition" send_response(id, handle_definition(params)) when "textDocument/hover" send_response(id, handle_hover(params)) when "textDocument/completion" send_response(id, handle_completion(params)) when "textDocument/diagnostic" send_response(id, handle_diagnostic(params)) when "shutdown" @running = false send_response(id, nil) when "exit" @running = false else send_error(-32601, "Method not found: #{method}", id: id) end end |
#read_header ⇒ Hash? (private)
Read HTTP-style header from input
83 84 85 86 87 88 89 90 91 92 |
# File 'lib/synthra/lsp/server.rb', line 83 def read_header header = {} loop do line = @input.gets return nil if line.nil? || line.strip.empty? key, value = line.split(":", 2) header[key.strip] = value&.strip end end |
#run ⇒ Object
Run the LSP server (main loop)
Reads JSON-RPC messages from input and processes them.
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
# File 'lib/synthra/lsp/server.rb', line 55 def run @running = true begin while @running header = read_header next unless header content_length = header["Content-Length"]&.to_i next unless content_length&.positive? = @input.read(content_length) next unless (JSON.parse()) end rescue JSON::ParserError => e send_error(-32700, "Parse error: #{e.}") rescue StandardError => e send_error(-32603, "Internal error: #{e.}") end end |
#send_error(code, message, id: nil) ⇒ Object (private)
Send JSON-RPC error
650 651 652 653 654 655 656 657 658 659 660 661 |
# File 'lib/synthra/lsp/server.rb', line 650 def send_error(code, , id: nil) error = { jsonrpc: "2.0", id: id, error: { code: code, message: } } (error) end |
#send_message(message) ⇒ Object (private)
Send JSON-RPC message
682 683 684 685 686 |
# File 'lib/synthra/lsp/server.rb', line 682 def () json = JSON.generate() @output.write("Content-Length: #{json.bytesize}\r\n\r\n#{json}") @output.flush end |
#send_notification(method, params) ⇒ Object (private)
Send JSON-RPC notification
668 669 670 671 672 673 674 675 676 |
# File 'lib/synthra/lsp/server.rb', line 668 def send_notification(method, params) = { jsonrpc: "2.0", method: method, params: params } () end |
#send_response(id, result) ⇒ Object (private)
Send JSON-RPC response
634 635 636 637 638 639 640 641 642 |
# File 'lib/synthra/lsp/server.rb', line 634 def send_response(id, result) = { jsonrpc: "2.0", id: id, result: result }.compact () end |
#type_documentation(name) ⇒ String (private)
Get documentation for a type
591 592 593 |
# File 'lib/synthra/lsp/server.rb', line 591 def type_documentation(name) "Type: #{name}" end |
#validate_document(uri) ⇒ Array<Hash> (private)
Validate a document and return diagnostics
368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 |
# File 'lib/synthra/lsp/server.rb', line 368 def validate_document(uri) doc = @documents[uri] return [] unless doc diagnostics = [] begin # Parse the document parser = Parser::Parser.new ast_nodes = parser.parse(doc[:content]) # Load into registry for validation temp_registry = Registry.new ast_nodes.each do |node| schema = node.to_schema temp_registry.instance_variable_get(:@schemas)[schema.name] = schema end # Validate paths validator = Validator::PathValidator.new(temp_registry) errors = validator.validate errors.each do |error| diagnostics << { range: { start: { line: (error.line || 0) - 1, character: (error.column || 0) - 1 }, end: { line: (error.line || 0) - 1, character: (error.column || 0) } }, severity: 1, # Error message: error., source: "Synthra" } end rescue Errors::ParseError => e diagnostics << { range: { start: { line: (e.line || 1) - 1, character: (e.column || 0) - 1 }, end: { line: (e.line || 1) - 1, character: (e.column || 0) } }, severity: 1, # Error message: e., source: "Synthra" } rescue StandardError => e diagnostics << { range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, severity: 1, # Error message: "Parse error: #{e.}", source: "Synthra" } end # Send diagnostics notification send_notification("textDocument/publishDiagnostics", { uri: uri, diagnostics: diagnostics }) diagnostics end |