Skip to content

Commit a0b5675

Browse files
ajitpratap0Ajit Pratap Singhclaude
authored
feat(CLI-009): implement Language Server Protocol (LSP) server (#128)
* feat(CLI-009): implement Language Server Protocol (LSP) server Add LSP server for IDE integration with the following features: - JSON-RPC 2.0 protocol handler over stdio - textDocument/didOpen, didChange, didClose, didSave synchronization - textDocument/publishDiagnostics for real-time syntax error detection - textDocument/hover for SQL keyword documentation (40+ keywords) - textDocument/completion for SQL keywords and functions (100+ items) - textDocument/formatting for basic SQL formatting New files: - pkg/lsp/protocol.go - LSP protocol type definitions - pkg/lsp/server.go - Main LSP server with JSON-RPC handling - pkg/lsp/handler.go - Request/notification handlers with SQL intelligence - pkg/lsp/documents.go - Document manager for open files - pkg/lsp/server_test.go - Comprehensive test coverage - cmd/gosqlx/cmd/lsp.go - CLI command to start LSP server Usage: gosqlx lsp # Start LSP server on stdio gosqlx lsp --log /tmp/lsp.log # With debug logging Integration guides included for VSCode, Neovim, and Emacs. Closes #76 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused offsetToPosition function (lint) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused test helper functions (staticcheck) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * docs: add LSP server documentation to README and CLAUDE.md - Add LSP server section to README.md with usage examples - Document IDE integration for VSCode and Neovim - Add LSP package to CLAUDE.md Core Components section - Add LSP command to CLI Tool Usage section 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: improve error handling in LSP server - Handle JSON marshaling errors in SendNotification instead of ignoring - Add method context to notification handler error logs - Add truncateForLog helper to avoid verbose error messages - Include raw params preview in error logs for debugging Addresses review comments on PR #128 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat(lsp): add enhancements for better IDE integration - Error responses: Send window/showMessage notifications on parse failures - Incremental sync: Enable incremental document sync for better performance with large files (SyncIncremental instead of SyncFull) - Position extraction: Parse error messages to extract line/column info from patterns like "at line X, column Y", "[L:C]", and "position N" - SQL snippets: Add 22 snippet completions for common SQL patterns (sel, seljoin, cte, cterec, merge, window, etc.) - Add ShowMessageParams and MessageType to protocol types - Add comprehensive tests for new functionality 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Ajit Pratap Singh <ajitpratapsingh@Ajits-Mac-mini.local> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 27c9f10 commit a0b5675

8 files changed

Lines changed: 2334 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ GoSQLX is a **production-ready**, **race-free**, high-performance SQL parsing SD
2828
- **Metrics** (`pkg/metrics/`): Production performance monitoring and observability
2929
- **Security** (`pkg/sql/security/`): SQL injection detection with pattern scanning and severity classification
3030
- **CLI** (`cmd/gosqlx/`): Production-ready command-line tool for SQL validation, formatting, and analysis
31+
- **LSP** (`pkg/lsp/`): Language Server Protocol server for IDE integration (diagnostics, hover, completion, formatting)
3132

3233
### Object Pooling Architecture
3334

@@ -142,6 +143,10 @@ go test -v example_test.go
142143
# Parse SQL to AST representation (JSON format)
143144
./gosqlx parse -f json complex_query.sql
144145

146+
# Start LSP server for IDE integration
147+
./gosqlx lsp
148+
./gosqlx lsp --log /tmp/lsp.log # With debug logging
149+
145150
# Install globally
146151
go install github.com/ajitpratap0/GoSQLX/cmd/gosqlx@latest
147152
```

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,42 @@ git diff --cached --name-only --diff-filter=ACM "*.sql" | \
172172
xargs cat | gosqlx validate --quiet
173173
```
174174

175+
**Language Server Protocol (LSP)** (New):
176+
```bash
177+
# Start LSP server for IDE integration
178+
gosqlx lsp
179+
180+
# With debug logging
181+
gosqlx lsp --log /tmp/gosqlx-lsp.log
182+
```
183+
184+
The LSP server provides real-time SQL intelligence for IDEs:
185+
- **Diagnostics**: Real-time syntax error detection
186+
- **Hover**: Documentation for 40+ SQL keywords
187+
- **Completion**: 100+ SQL keywords and functions
188+
- **Formatting**: SQL code formatting
189+
190+
**IDE Integration:**
191+
```jsonc
192+
// VSCode settings.json
193+
{
194+
"gosqlx.lsp.enable": true,
195+
"gosqlx.lsp.path": "gosqlx"
196+
}
197+
```
198+
199+
```lua
200+
-- Neovim (nvim-lspconfig)
201+
require('lspconfig.configs').gosqlx = {
202+
default_config = {
203+
cmd = { 'gosqlx', 'lsp' },
204+
filetypes = { 'sql' },
205+
root_dir = function() return vim.fn.getcwd() end,
206+
},
207+
}
208+
require('lspconfig').gosqlx.setup{}
209+
```
210+
175211
### Library Usage - Simple API
176212

177213
GoSQLX provides a simple, high-level API that handles all complexity for you:

cmd/gosqlx/cmd/lsp.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"log"
7+
"os"
8+
9+
"github.com/ajitpratap0/GoSQLX/pkg/lsp"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
var (
14+
lspLogFile string
15+
)
16+
17+
// lspCmd represents the lsp command
18+
var lspCmd = &cobra.Command{
19+
Use: "lsp",
20+
Short: "Start Language Server Protocol (LSP) server",
21+
Long: `Start the GoSQLX LSP server for IDE integration.
22+
23+
The LSP server provides real-time SQL validation, formatting,
24+
hover documentation, and code completion for IDEs and text editors.
25+
26+
Features:
27+
- Real-time syntax error detection
28+
- SQL formatting
29+
- Keyword documentation on hover
30+
- SQL keyword and function completion
31+
32+
Examples:
33+
gosqlx lsp # Start LSP server on stdio
34+
gosqlx lsp --log /tmp/lsp.log # Start with logging enabled
35+
36+
VSCode Integration:
37+
Add to your settings.json:
38+
{
39+
"gosqlx.lsp.enable": true,
40+
"gosqlx.lsp.path": "gosqlx"
41+
}
42+
43+
Neovim Integration (nvim-lspconfig):
44+
require('lspconfig.configs').gosqlx = {
45+
default_config = {
46+
cmd = { 'gosqlx', 'lsp' },
47+
filetypes = { 'sql' },
48+
root_dir = function() return vim.fn.getcwd() end,
49+
},
50+
}
51+
require('lspconfig').gosqlx.setup{}
52+
53+
Emacs Integration (lsp-mode):
54+
(lsp-register-client
55+
(make-lsp-client
56+
:new-connection (lsp-stdio-connection '("gosqlx" "lsp"))
57+
:major-modes '(sql-mode)
58+
:server-id 'gosqlx))`,
59+
RunE: lspRun,
60+
}
61+
62+
func init() {
63+
rootCmd.AddCommand(lspCmd)
64+
65+
lspCmd.Flags().StringVar(&lspLogFile, "log", "", "Log file path (optional, for debugging)")
66+
}
67+
68+
func lspRun(cmd *cobra.Command, args []string) error {
69+
// Set up logging
70+
var logger *log.Logger
71+
if lspLogFile != "" {
72+
f, err := os.OpenFile(lspLogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
73+
if err != nil {
74+
return fmt.Errorf("failed to open log file: %w", err)
75+
}
76+
defer f.Close()
77+
logger = log.New(f, "[gosqlx-lsp] ", log.LstdFlags|log.Lshortfile)
78+
} else {
79+
// Discard logs by default (LSP should only communicate via protocol)
80+
logger = log.New(io.Discard, "", 0)
81+
}
82+
83+
logger.Println("Starting GoSQLX LSP server...")
84+
85+
// Create and run the LSP server
86+
server := lsp.NewStdioServer(logger)
87+
return server.Run()
88+
}

pkg/lsp/documents.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package lsp
2+
3+
import (
4+
"strings"
5+
"sync"
6+
)
7+
8+
// DocumentManager manages open documents
9+
type DocumentManager struct {
10+
mu sync.RWMutex
11+
documents map[string]*Document
12+
}
13+
14+
// Document represents an open SQL document
15+
type Document struct {
16+
URI string
17+
LanguageID string
18+
Version int
19+
Content string
20+
Lines []string // Cached line splits
21+
}
22+
23+
// NewDocumentManager creates a new document manager
24+
func NewDocumentManager() *DocumentManager {
25+
return &DocumentManager{
26+
documents: make(map[string]*Document),
27+
}
28+
}
29+
30+
// Open adds a document to the manager
31+
func (dm *DocumentManager) Open(uri, languageID string, version int, content string) {
32+
dm.mu.Lock()
33+
defer dm.mu.Unlock()
34+
dm.documents[uri] = &Document{
35+
URI: uri,
36+
LanguageID: languageID,
37+
Version: version,
38+
Content: content,
39+
Lines: splitLines(content),
40+
}
41+
}
42+
43+
// Update updates a document's content
44+
func (dm *DocumentManager) Update(uri string, version int, changes []TextDocumentContentChangeEvent) {
45+
dm.mu.Lock()
46+
defer dm.mu.Unlock()
47+
48+
doc, ok := dm.documents[uri]
49+
if !ok {
50+
return
51+
}
52+
53+
doc.Version = version
54+
55+
for _, change := range changes {
56+
if change.Range == nil {
57+
// Full document sync
58+
doc.Content = change.Text
59+
doc.Lines = splitLines(change.Text)
60+
} else {
61+
// Incremental sync
62+
doc.Content = applyChange(doc.Content, doc.Lines, change)
63+
doc.Lines = splitLines(doc.Content)
64+
}
65+
}
66+
}
67+
68+
// Close removes a document from the manager
69+
func (dm *DocumentManager) Close(uri string) {
70+
dm.mu.Lock()
71+
defer dm.mu.Unlock()
72+
delete(dm.documents, uri)
73+
}
74+
75+
// Get retrieves a document
76+
func (dm *DocumentManager) Get(uri string) (*Document, bool) {
77+
dm.mu.RLock()
78+
defer dm.mu.RUnlock()
79+
doc, ok := dm.documents[uri]
80+
return doc, ok
81+
}
82+
83+
// GetContent retrieves a document's content
84+
func (dm *DocumentManager) GetContent(uri string) (string, bool) {
85+
dm.mu.RLock()
86+
defer dm.mu.RUnlock()
87+
doc, ok := dm.documents[uri]
88+
if !ok {
89+
return "", false
90+
}
91+
return doc.Content, true
92+
}
93+
94+
// splitLines splits content into lines, preserving line endings
95+
func splitLines(content string) []string {
96+
if content == "" {
97+
return []string{""}
98+
}
99+
lines := strings.Split(content, "\n")
100+
return lines
101+
}
102+
103+
// applyChange applies an incremental change to the document
104+
func applyChange(content string, lines []string, change TextDocumentContentChangeEvent) string {
105+
if change.Range == nil {
106+
return change.Text
107+
}
108+
109+
startOffset := positionToOffset(lines, change.Range.Start)
110+
endOffset := positionToOffset(lines, change.Range.End)
111+
112+
// Build new content
113+
var result strings.Builder
114+
result.WriteString(content[:startOffset])
115+
result.WriteString(change.Text)
116+
if endOffset < len(content) {
117+
result.WriteString(content[endOffset:])
118+
}
119+
120+
return result.String()
121+
}
122+
123+
// positionToOffset converts a Position to a byte offset
124+
func positionToOffset(lines []string, pos Position) int {
125+
offset := 0
126+
for i := 0; i < pos.Line && i < len(lines); i++ {
127+
offset += len(lines[i]) + 1 // +1 for newline
128+
}
129+
if pos.Line < len(lines) {
130+
lineLen := len(lines[pos.Line])
131+
if pos.Character < lineLen {
132+
offset += pos.Character
133+
} else {
134+
offset += lineLen
135+
}
136+
}
137+
return offset
138+
}
139+
140+
// GetWordAtPosition returns the word at the given position
141+
func (doc *Document) GetWordAtPosition(pos Position) string {
142+
if pos.Line >= len(doc.Lines) {
143+
return ""
144+
}
145+
146+
line := doc.Lines[pos.Line]
147+
if pos.Character >= len(line) {
148+
return ""
149+
}
150+
151+
// Find word boundaries
152+
start := pos.Character
153+
end := pos.Character
154+
155+
// Move start backwards to find word start
156+
for start > 0 && isWordChar(rune(line[start-1])) {
157+
start--
158+
}
159+
160+
// Move end forwards to find word end
161+
for end < len(line) && isWordChar(rune(line[end])) {
162+
end++
163+
}
164+
165+
if start == end {
166+
return ""
167+
}
168+
169+
return line[start:end]
170+
}
171+
172+
// isWordChar returns true if c is a valid word character
173+
func isWordChar(c rune) bool {
174+
return (c >= 'a' && c <= 'z') ||
175+
(c >= 'A' && c <= 'Z') ||
176+
(c >= '0' && c <= '9') ||
177+
c == '_'
178+
}

0 commit comments

Comments
 (0)