feat: publish the Hime-san Beta 2 LSP

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>
This commit is contained in:
2026-08-12 20:31:07 -04:00
parent e3a94fd16b
commit 1082d9d61e
25 changed files with 2463 additions and 89 deletions
+371
View File
@@ -0,0 +1,371 @@
// 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)
}