Add the standard-library language server, editor-neutral protocol contract, additive version feature discovery, and bounded security regressions while leaving the Sando runtime unchanged. Material design and implementation assistance was provided by OpenAI Codex. Signed-off-by: Cole Speelman <crspeelman@gmail.com>
372 lines
12 KiB
Go
372 lines
12 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
// Package lsp implements Hime-san's read-only Language Server Protocol
|
|
// adapter. It deliberately owns no generation, Go toolchain, HTTP, network,
|
|
// or project-execution behavior.
|
|
package lsp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gamertan.com/sandwich-hime/internal/compiler"
|
|
)
|
|
|
|
// Options configures one stdio language-server process.
|
|
type Options struct {
|
|
Input io.Reader
|
|
Output io.Writer
|
|
LogOutput io.Writer
|
|
Debounce time.Duration
|
|
}
|
|
|
|
// Server serves exactly one workspace root.
|
|
type Server struct {
|
|
context context.Context
|
|
cancel context.CancelFunc
|
|
reader *frameReader
|
|
writer *frameWriter
|
|
logs io.Writer
|
|
|
|
mu sync.RWMutex
|
|
root string
|
|
initialized bool
|
|
shutdown bool
|
|
overlays map[string]document
|
|
snapshot workspaceSnapshot
|
|
analysisCancel context.CancelFunc
|
|
analysisTimer *time.Timer
|
|
analysisGeneration uint64
|
|
debounce time.Duration
|
|
afterFunc func(time.Duration, func()) *time.Timer
|
|
requests map[string]context.CancelFunc
|
|
wait sync.WaitGroup
|
|
analysisWait sync.WaitGroup
|
|
}
|
|
|
|
// Run serves LSP JSON-RPC until the client sends exit, closes stdin, or the
|
|
// parent context is canceled.
|
|
func Run(parent context.Context, options Options) error {
|
|
if options.Input == nil || options.Output == nil {
|
|
return errors.New("LSP stdin and stdout are required")
|
|
}
|
|
if options.LogOutput == nil {
|
|
options.LogOutput = io.Discard
|
|
}
|
|
if options.Debounce <= 0 {
|
|
options.Debounce = 200 * time.Millisecond
|
|
}
|
|
ctx, cancel := context.WithCancel(parent)
|
|
server := &Server{
|
|
context: ctx, cancel: cancel,
|
|
reader: newFrameReader(options.Input), writer: &frameWriter{output: options.Output}, logs: options.LogOutput,
|
|
overlays: make(map[string]document), snapshot: workspaceSnapshot{documents: make(map[string]document), analyses: make(map[string]compiler.DocumentAnalysis)},
|
|
debounce: options.Debounce, afterFunc: time.AfterFunc, requests: make(map[string]context.CancelFunc),
|
|
}
|
|
defer func() {
|
|
cancel()
|
|
server.mu.Lock()
|
|
server.analysisGeneration++
|
|
if server.analysisTimer != nil {
|
|
server.analysisTimer.Stop()
|
|
}
|
|
if server.analysisCancel != nil {
|
|
server.analysisCancel()
|
|
}
|
|
for _, requestCancel := range server.requests {
|
|
requestCancel()
|
|
}
|
|
server.mu.Unlock()
|
|
server.wait.Wait()
|
|
server.analysisWait.Wait()
|
|
}()
|
|
|
|
for {
|
|
payload, err := server.reader.read()
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("read LSP frame: %w", err)
|
|
}
|
|
var message rpcMessage
|
|
if err := json.Unmarshal(payload, &message); err != nil {
|
|
_ = server.writer.write(rpcMessage{JSONRPC: "2.0", ID: json.RawMessage("null"), Error: &rpcError{Code: errParse, Message: "invalid JSON"}})
|
|
continue
|
|
}
|
|
if message.JSONRPC != "2.0" || message.Method == "" || !validRequestID(message.ID) {
|
|
_ = server.writer.write(rpcMessage{JSONRPC: "2.0", ID: responseID(message.ID), Error: &rpcError{Code: errInvalidRequest, Message: "invalid JSON-RPC request"}})
|
|
continue
|
|
}
|
|
if len(message.ID) == 0 {
|
|
if message.Method == "exit" {
|
|
server.cancel()
|
|
return nil
|
|
}
|
|
server.handleNotification(message.Method, message.Params)
|
|
continue
|
|
}
|
|
server.startRequest(message)
|
|
}
|
|
}
|
|
|
|
func responseID(id json.RawMessage) json.RawMessage {
|
|
if len(id) == 0 {
|
|
return json.RawMessage("null")
|
|
}
|
|
return append(json.RawMessage(nil), id...)
|
|
}
|
|
|
|
func (server *Server) startRequest(message rpcMessage) {
|
|
key := string(message.ID)
|
|
ctx, cancel := context.WithCancel(server.context)
|
|
server.mu.Lock()
|
|
server.requests[key] = cancel
|
|
server.mu.Unlock()
|
|
server.wait.Add(1)
|
|
go func() {
|
|
defer server.wait.Done()
|
|
defer cancel()
|
|
result, rpcErr := server.handleRequest(ctx, message.Method, message.Params)
|
|
if ctx.Err() != nil && rpcErr == nil {
|
|
rpcErr = &rpcError{Code: errRequestCancelled, Message: "request canceled"}
|
|
}
|
|
if rpcErr == nil && result == nil {
|
|
result = json.RawMessage("null")
|
|
}
|
|
server.mu.Lock()
|
|
delete(server.requests, key)
|
|
server.mu.Unlock()
|
|
_ = server.writer.write(rpcMessage{JSONRPC: "2.0", ID: responseID(message.ID), Result: result, Error: rpcErr})
|
|
}()
|
|
}
|
|
|
|
func (server *Server) handleRequest(ctx context.Context, method string, params json.RawMessage) (any, *rpcError) {
|
|
switch method {
|
|
case "initialize":
|
|
return server.initialize(params)
|
|
case "shutdown":
|
|
server.mu.Lock()
|
|
server.shutdown = true
|
|
if server.analysisCancel != nil {
|
|
server.analysisCancel()
|
|
}
|
|
server.mu.Unlock()
|
|
return nil, nil
|
|
}
|
|
server.mu.RLock()
|
|
ready := server.initialized && !server.shutdown
|
|
server.mu.RUnlock()
|
|
if !ready {
|
|
return nil, &rpcError{Code: errInvalidRequest, Message: "language server is not initialized"}
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, &rpcError{Code: errRequestCancelled, Message: "request canceled"}
|
|
default:
|
|
}
|
|
switch method {
|
|
case "textDocument/completion":
|
|
var request textDocumentPositionParams
|
|
if err := json.Unmarshal(params, &request); err != nil {
|
|
return nil, &rpcError{Code: errInvalidParams, Message: "invalid completion parameters"}
|
|
}
|
|
return server.completions(request), nil
|
|
case "textDocument/hover":
|
|
var request textDocumentPositionParams
|
|
if err := json.Unmarshal(params, &request); err != nil {
|
|
return nil, &rpcError{Code: errInvalidParams, Message: "invalid hover parameters"}
|
|
}
|
|
return server.hover(request), nil
|
|
case "textDocument/definition":
|
|
var request textDocumentPositionParams
|
|
if err := json.Unmarshal(params, &request); err != nil {
|
|
return nil, &rpcError{Code: errInvalidParams, Message: "invalid definition parameters"}
|
|
}
|
|
return server.definition(request), nil
|
|
case "textDocument/documentSymbol":
|
|
var request struct {
|
|
TextDocument textDocumentIdentifier `json:"textDocument"`
|
|
}
|
|
if err := json.Unmarshal(params, &request); err != nil {
|
|
return nil, &rpcError{Code: errInvalidParams, Message: "invalid document-symbol parameters"}
|
|
}
|
|
return server.documentSymbols(request.TextDocument.URI), nil
|
|
default:
|
|
return nil, &rpcError{Code: errMethodNotFound, Message: "method not supported"}
|
|
}
|
|
}
|
|
|
|
func (server *Server) initialize(params json.RawMessage) (any, *rpcError) {
|
|
var request struct {
|
|
RootURI string `json:"rootUri"`
|
|
RootPath string `json:"rootPath"`
|
|
WorkspaceFolders []struct {
|
|
URI string `json:"uri"`
|
|
} `json:"workspaceFolders"`
|
|
}
|
|
if err := json.Unmarshal(params, &request); err != nil {
|
|
return nil, &rpcError{Code: errInvalidParams, Message: "invalid initialize parameters"}
|
|
}
|
|
if len(request.WorkspaceFolders) > 1 {
|
|
return nil, &rpcError{Code: errInvalidParams, Message: "Hime-san accepts one workspace folder per language-server process"}
|
|
}
|
|
rootURI := request.RootURI
|
|
if len(request.WorkspaceFolders) == 1 {
|
|
rootURI = request.WorkspaceFolders[0].URI
|
|
}
|
|
var root string
|
|
var err error
|
|
if rootURI != "" {
|
|
root, err = fileURIToPath(rootURI)
|
|
} else if request.RootPath != "" {
|
|
root, err = filepath.Abs(request.RootPath)
|
|
} else {
|
|
root, err = os.Getwd()
|
|
}
|
|
if err != nil {
|
|
return nil, &rpcError{Code: errInvalidParams, Message: "workspace root is not a local filesystem path"}
|
|
}
|
|
info, err := os.Lstat(root)
|
|
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
|
return nil, &rpcError{Code: errInvalidParams, Message: "workspace root must be an existing non-symlink directory"}
|
|
}
|
|
evaluated, err := filepath.EvalSymlinks(root)
|
|
if err != nil || filepath.Clean(evaluated) != filepath.Clean(root) {
|
|
return nil, &rpcError{Code: errInvalidParams, Message: "workspace roots reached through symlinks are not supported"}
|
|
}
|
|
server.mu.Lock()
|
|
if server.initialized {
|
|
server.mu.Unlock()
|
|
return nil, &rpcError{Code: errInvalidRequest, Message: "initialize may be sent only once"}
|
|
}
|
|
server.root = filepath.Clean(root)
|
|
server.initialized = true
|
|
server.mu.Unlock()
|
|
return struct {
|
|
Capabilities any `json:"capabilities"`
|
|
ServerInfo any `json:"serverInfo"`
|
|
}{
|
|
Capabilities: map[string]any{
|
|
"positionEncoding": "utf-16",
|
|
"textDocumentSync": map[string]any{"openClose": true, "change": 1, "save": map[string]any{"includeText": true}},
|
|
"completionProvider": map[string]any{"triggerCharacters": []string{"<", "?", "~", "."}, "resolveProvider": false},
|
|
"hoverProvider": true, "definitionProvider": true, "documentSymbolProvider": true,
|
|
"workspace": map[string]any{"workspaceFolders": map[string]any{"supported": false, "changeNotifications": false}},
|
|
},
|
|
ServerInfo: map[string]any{"name": "himesan", "version": compiler.CompilerVersion},
|
|
}, nil
|
|
}
|
|
|
|
func (server *Server) handleNotification(method string, params json.RawMessage) {
|
|
switch method {
|
|
case "initialized":
|
|
server.scheduleReindex(false)
|
|
case "$/cancelRequest":
|
|
var request struct {
|
|
ID json.RawMessage `json:"id"`
|
|
}
|
|
if json.Unmarshal(params, &request) == nil {
|
|
server.mu.RLock()
|
|
cancel := server.requests[string(request.ID)]
|
|
server.mu.RUnlock()
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
}
|
|
case "textDocument/didOpen":
|
|
var request struct {
|
|
TextDocument struct {
|
|
URI string `json:"uri"`
|
|
Version int `json:"version"`
|
|
Text string `json:"text"`
|
|
} `json:"textDocument"`
|
|
}
|
|
if json.Unmarshal(params, &request) == nil {
|
|
server.updateOverlay(request.TextDocument.URI, request.TextDocument.Version, request.TextDocument.Text, true)
|
|
server.scheduleReindex(false)
|
|
}
|
|
case "textDocument/didChange":
|
|
var request struct {
|
|
TextDocument versionedTextDocumentIdentifier `json:"textDocument"`
|
|
ContentChanges []struct {
|
|
Range *Range `json:"range,omitempty"`
|
|
Text string `json:"text"`
|
|
} `json:"contentChanges"`
|
|
}
|
|
if json.Unmarshal(params, &request) == nil && len(request.ContentChanges) != 0 {
|
|
change := request.ContentChanges[len(request.ContentChanges)-1]
|
|
if change.Range == nil {
|
|
server.updateOverlay(request.TextDocument.URI, request.TextDocument.Version, change.Text, true)
|
|
server.scheduleReindex(true)
|
|
}
|
|
}
|
|
case "textDocument/didSave":
|
|
var request struct {
|
|
TextDocument textDocumentIdentifier `json:"textDocument"`
|
|
Text *string `json:"text,omitempty"`
|
|
}
|
|
if json.Unmarshal(params, &request) == nil {
|
|
if request.Text != nil {
|
|
server.updateOverlay(request.TextDocument.URI, -1, *request.Text, true)
|
|
}
|
|
server.scheduleReindex(false)
|
|
}
|
|
case "textDocument/didClose":
|
|
var request struct {
|
|
TextDocument textDocumentIdentifier `json:"textDocument"`
|
|
}
|
|
if json.Unmarshal(params, &request) == nil {
|
|
if path, err := fileURIToPath(request.TextDocument.URI); err == nil {
|
|
server.mu.Lock()
|
|
delete(server.overlays, path)
|
|
server.mu.Unlock()
|
|
server.scheduleReindex(false)
|
|
}
|
|
}
|
|
case "workspace/didChangeWatchedFiles":
|
|
server.scheduleReindex(false)
|
|
}
|
|
}
|
|
|
|
func (server *Server) updateOverlay(uri string, version int, text string, open bool) {
|
|
if len(text) > maxDocumentBytes || !strings.HasSuffix(strings.ToLower(uri), ".sando") {
|
|
return
|
|
}
|
|
path, err := fileURIToPath(uri)
|
|
if err != nil {
|
|
return
|
|
}
|
|
server.mu.Lock()
|
|
defer server.mu.Unlock()
|
|
if !editorPathAllowed(server.root, path) {
|
|
return
|
|
}
|
|
if previous, ok := server.overlays[path]; ok && version < 0 {
|
|
version = previous.Version
|
|
}
|
|
server.overlays[path] = document{URI: uri, Path: path, Text: []byte(text), Version: version, Open: open}
|
|
}
|
|
|
|
func (server *Server) notify(method string, params any) error {
|
|
payload, err := json.Marshal(params)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return server.writer.write(rpcMessage{JSONRPC: "2.0", Method: method, Params: payload})
|
|
}
|
|
|
|
func (server *Server) log(message string, count int) {
|
|
// Logs deliberately contain only fixed messages and counts. Source text,
|
|
// paths, environment values, and process details never cross this boundary.
|
|
fmt.Fprintf(server.logs, "himesan lsp: %s (%d)\n", message, count)
|
|
}
|