Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ GoSQLX is a **production-ready**, **race-free**, high-performance SQL parsing SD
- **Metrics** (`pkg/metrics/`): Production performance monitoring and observability
- **Security** (`pkg/sql/security/`): SQL injection detection with pattern scanning and severity classification
- **CLI** (`cmd/gosqlx/`): Production-ready command-line tool for SQL validation, formatting, and analysis
- **LSP** (`pkg/lsp/`): Language Server Protocol server for IDE integration (diagnostics, hover, completion, formatting)

### Object Pooling Architecture

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

# Start LSP server for IDE integration
./gosqlx lsp
./gosqlx lsp --log /tmp/lsp.log # With debug logging

# Install globally
go install github.com/ajitpratap0/GoSQLX/cmd/gosqlx@latest
```
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,42 @@ git diff --cached --name-only --diff-filter=ACM "*.sql" | \
xargs cat | gosqlx validate --quiet
```

**Language Server Protocol (LSP)** (New):
```bash
# Start LSP server for IDE integration
gosqlx lsp

# With debug logging
gosqlx lsp --log /tmp/gosqlx-lsp.log
```

The LSP server provides real-time SQL intelligence for IDEs:
- **Diagnostics**: Real-time syntax error detection
- **Hover**: Documentation for 40+ SQL keywords
- **Completion**: 100+ SQL keywords and functions
- **Formatting**: SQL code formatting

**IDE Integration:**
```jsonc
// VSCode settings.json
{
"gosqlx.lsp.enable": true,
"gosqlx.lsp.path": "gosqlx"
}
```

```lua
-- Neovim (nvim-lspconfig)
require('lspconfig.configs').gosqlx = {
default_config = {
cmd = { 'gosqlx', 'lsp' },
filetypes = { 'sql' },
root_dir = function() return vim.fn.getcwd() end,
},
}
require('lspconfig').gosqlx.setup{}
```

### Library Usage - Simple API

GoSQLX provides a simple, high-level API that handles all complexity for you:
Expand Down
88 changes: 88 additions & 0 deletions cmd/gosqlx/cmd/lsp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package cmd

import (
"fmt"
"io"
"log"
"os"

"github.com/ajitpratap0/GoSQLX/pkg/lsp"
"github.com/spf13/cobra"
)

var (
lspLogFile string
)

// lspCmd represents the lsp command
var lspCmd = &cobra.Command{
Use: "lsp",
Short: "Start Language Server Protocol (LSP) server",
Long: `Start the GoSQLX LSP server for IDE integration.

The LSP server provides real-time SQL validation, formatting,
hover documentation, and code completion for IDEs and text editors.

Features:
- Real-time syntax error detection
- SQL formatting
- Keyword documentation on hover
- SQL keyword and function completion

Examples:
gosqlx lsp # Start LSP server on stdio
gosqlx lsp --log /tmp/lsp.log # Start with logging enabled

VSCode Integration:
Add to your settings.json:
{
"gosqlx.lsp.enable": true,
"gosqlx.lsp.path": "gosqlx"
}

Neovim Integration (nvim-lspconfig):
require('lspconfig.configs').gosqlx = {
default_config = {
cmd = { 'gosqlx', 'lsp' },
filetypes = { 'sql' },
root_dir = function() return vim.fn.getcwd() end,
},
}
require('lspconfig').gosqlx.setup{}

Emacs Integration (lsp-mode):
(lsp-register-client
(make-lsp-client
:new-connection (lsp-stdio-connection '("gosqlx" "lsp"))
:major-modes '(sql-mode)
:server-id 'gosqlx))`,
RunE: lspRun,
}

func init() {
rootCmd.AddCommand(lspCmd)

lspCmd.Flags().StringVar(&lspLogFile, "log", "", "Log file path (optional, for debugging)")
}

func lspRun(cmd *cobra.Command, args []string) error {
// Set up logging
var logger *log.Logger
if lspLogFile != "" {
f, err := os.OpenFile(lspLogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return fmt.Errorf("failed to open log file: %w", err)
}
defer f.Close()
logger = log.New(f, "[gosqlx-lsp] ", log.LstdFlags|log.Lshortfile)
} else {
// Discard logs by default (LSP should only communicate via protocol)
logger = log.New(io.Discard, "", 0)
}

logger.Println("Starting GoSQLX LSP server...")

// Create and run the LSP server
server := lsp.NewStdioServer(logger)
return server.Run()
}
178 changes: 178 additions & 0 deletions pkg/lsp/documents.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package lsp

import (
"strings"
"sync"
)

// DocumentManager manages open documents
type DocumentManager struct {
mu sync.RWMutex
documents map[string]*Document
}

// Document represents an open SQL document
type Document struct {
URI string
LanguageID string
Version int
Content string
Lines []string // Cached line splits
}

// NewDocumentManager creates a new document manager
func NewDocumentManager() *DocumentManager {
return &DocumentManager{
documents: make(map[string]*Document),
}
}

// Open adds a document to the manager
func (dm *DocumentManager) Open(uri, languageID string, version int, content string) {
dm.mu.Lock()
defer dm.mu.Unlock()
dm.documents[uri] = &Document{
URI: uri,
LanguageID: languageID,
Version: version,
Content: content,
Lines: splitLines(content),
}
}

// Update updates a document's content
func (dm *DocumentManager) Update(uri string, version int, changes []TextDocumentContentChangeEvent) {
dm.mu.Lock()
defer dm.mu.Unlock()

doc, ok := dm.documents[uri]
if !ok {
return
}

doc.Version = version

for _, change := range changes {
if change.Range == nil {
// Full document sync
doc.Content = change.Text
doc.Lines = splitLines(change.Text)
} else {
// Incremental sync
doc.Content = applyChange(doc.Content, doc.Lines, change)
doc.Lines = splitLines(doc.Content)
}
}
}

// Close removes a document from the manager
func (dm *DocumentManager) Close(uri string) {
dm.mu.Lock()
defer dm.mu.Unlock()
delete(dm.documents, uri)
}

// Get retrieves a document
func (dm *DocumentManager) Get(uri string) (*Document, bool) {
dm.mu.RLock()
defer dm.mu.RUnlock()
doc, ok := dm.documents[uri]
return doc, ok
}

// GetContent retrieves a document's content
func (dm *DocumentManager) GetContent(uri string) (string, bool) {
dm.mu.RLock()
defer dm.mu.RUnlock()
doc, ok := dm.documents[uri]
if !ok {
return "", false
}
return doc.Content, true
}

// splitLines splits content into lines, preserving line endings
func splitLines(content string) []string {
if content == "" {
return []string{""}
}
lines := strings.Split(content, "\n")
return lines
}

// applyChange applies an incremental change to the document
func applyChange(content string, lines []string, change TextDocumentContentChangeEvent) string {
if change.Range == nil {
return change.Text
}

startOffset := positionToOffset(lines, change.Range.Start)
endOffset := positionToOffset(lines, change.Range.End)

// Build new content
var result strings.Builder
result.WriteString(content[:startOffset])
result.WriteString(change.Text)
if endOffset < len(content) {
result.WriteString(content[endOffset:])
}

return result.String()
}

// positionToOffset converts a Position to a byte offset
func positionToOffset(lines []string, pos Position) int {
offset := 0
for i := 0; i < pos.Line && i < len(lines); i++ {
offset += len(lines[i]) + 1 // +1 for newline
}
if pos.Line < len(lines) {
lineLen := len(lines[pos.Line])
if pos.Character < lineLen {
offset += pos.Character
} else {
offset += lineLen
}
}
return offset
}

// GetWordAtPosition returns the word at the given position
func (doc *Document) GetWordAtPosition(pos Position) string {
if pos.Line >= len(doc.Lines) {
return ""
}

line := doc.Lines[pos.Line]
if pos.Character >= len(line) {
return ""
}

// Find word boundaries
start := pos.Character
end := pos.Character

// Move start backwards to find word start
for start > 0 && isWordChar(rune(line[start-1])) {
start--
}

// Move end forwards to find word end
for end < len(line) && isWordChar(rune(line[end])) {
end++
}

if start == end {
return ""
}

return line[start:end]
}

// isWordChar returns true if c is a valid word character
func isWordChar(c rune) bool {
return (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '_'
}
Loading
Loading