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
+304
View File
@@ -0,0 +1,304 @@
// SPDX-License-Identifier: AGPL-3.0-only
package lsp
import (
"bytes"
"fmt"
"strings"
"unicode"
"unicode/utf8"
"gamertan.com/sandwich-hime/internal/compiler"
)
type completionItem struct {
Label string `json:"label"`
Kind int `json:"kind,omitempty"`
Detail string `json:"detail,omitempty"`
Documentation any `json:"documentation,omitempty"`
InsertText string `json:"insertText,omitempty"`
InsertTextFormat int `json:"insertTextFormat,omitempty"`
SortText string `json:"sortText,omitempty"`
}
type markupContent struct {
Kind string `json:"kind"`
Value string `json:"value"`
}
type hoverResult struct {
Contents markupContent `json:"contents"`
Range *Range `json:"range,omitempty"`
}
type documentSymbol struct {
Name string `json:"name"`
Detail string `json:"detail,omitempty"`
Kind int `json:"kind"`
Range Range `json:"range"`
SelectionRange Range `json:"selectionRange"`
Children []documentSymbol `json:"children,omitempty"`
}
func (server *Server) completions(request textDocumentPositionParams) any {
document, analysis, ok := server.snapshotDocument(request.TextDocument.URI)
if !ok {
return []completionItem{}
}
offset, ok := positionToOffset(document.Text, request.Position)
if !ok {
return []completionItem{}
}
open := bytes.LastIndex(document.Text[:offset], []byte("<?"))
close := bytes.LastIndex(document.Text[:offset], []byte("?>"))
if open >= 0 && open > close && bytes.HasPrefix(document.Text[open:offset], []byte("<?~")) {
server.mu.RLock()
snapshot := server.snapshot
server.mu.RUnlock()
prefix := strings.TrimSpace(string(document.Text[open+3 : offset]))
items := make([]completionItem, 0)
for _, target := range snapshot.componentsFor(document.Path, analysis) {
label := target.Label()
if prefix != "" && !strings.HasPrefix(label, prefix) {
continue
}
items = append(items, completionItem{
Label: label, Kind: 3, Detail: target.Signature,
Documentation: markupContent{Kind: "markdown", Value: "Typed `.sando` component. Hime-san emits an ordinary Go constructor."},
InsertText: label, InsertTextFormat: 1, SortText: "1-" + label,
})
}
return struct {
IsIncomplete bool `json:"isIncomplete"`
Items []completionItem `json:"items"`
}{Items: items}
}
return struct {
IsIncomplete bool `json:"isIncomplete"`
Items []completionItem `json:"items"`
}{Items: tagCompletions()}
}
func tagCompletions() []completionItem {
return []completionItem{
{Label: "<?sando go", Kind: 15, Detail: "component file header", InsertText: "<?sando go\npackage ${1:views}\nfunc ${2:Component}(${3:})\n?>", InsertTextFormat: 2, SortText: "0-header"},
{Label: "<? … ?>", Kind: 15, Detail: "Go statement", InsertText: "<? ${1:if condition {} } ?>", InsertTextFormat: 2, SortText: "0-statement"},
{Label: "<?= … ?>", Kind: 15, Detail: "contextually escaped expression", InsertText: "<?= ${1:value} ?>", InsertTextFormat: 2, SortText: "0-expression"},
{Label: "<?~ … ?>", Kind: 15, Detail: "typed component composition", InsertText: "<?~ ${1:Component()} ?>", InsertTextFormat: 2, SortText: "0-component"},
{Label: "<?# … ?>", Kind: 15, Detail: "Hime-san template comment", InsertText: "<?# ${1:comment} ?>", InsertTextFormat: 2, SortText: "0-comment"},
}
}
func (server *Server) hover(request textDocumentPositionParams) any {
document, analysis, ok := server.snapshotDocument(request.TextDocument.URI)
if !ok {
return nil
}
offset, ok := positionToOffset(document.Text, request.Position)
if !ok {
return nil
}
for _, region := range analysis.Regions {
if offset < region.Offset || offset > region.Offset+region.Length {
continue
}
if region.Kind == compiler.AnalysisComponent {
qualifier, name := referenceAt(region.Text, offset-region.Offset)
if name != "" {
if target, found := server.resolveComponent(document.Path, analysis, qualifier, name); found {
value := "```go\n" + target.Signature + "\n```\n\nTyped component composition. Handwritten `sando.Component` values are trusted output capabilities."
return hoverResult{Contents: markupContent{Kind: "markdown", Value: value}}
}
}
}
value := fmt.Sprintf("**Hime-san %s region**\n\nOutput context: `%s`.", region.Kind, region.Context)
if region.Context == compiler.ContextJS || region.Context == compiler.ContextCSS || strings.Contains(region.Text, "Trust") {
value += "\n\nTrusted output is an explicit security capability; audit its provenance and parser-state effects."
}
return hoverResult{Contents: markupContent{Kind: "markdown", Value: value}}
}
if marker, start, end := enclosingTag(document.Text, offset); marker != "" {
if text := tagDocumentation(marker); text != "" {
rangeValue := Range{Start: offsetToPosition(document.Text, start), End: offsetToPosition(document.Text, end)}
return hoverResult{Contents: markupContent{Kind: "markdown", Value: text}, Range: &rangeValue}
}
}
return nil
}
func tagDocumentation(marker string) string {
switch marker {
case "<?sando":
return "**`<?sando go … ?>`** declares the Go package, imports, and one typed component signature. It must be the first non-whitespace content."
case "<?~":
return "**`<?~ … ?>`** composes a typed `sando.Component` at an HTML content boundary. It is not template inheritance."
case "<?=":
return "**`<?= … ?>`** renders an expression through the helper selected by Hime-san's inferred HTML output context."
case "<?#":
return "**`<?# … ?>`** is a Hime-san comment. It emits no bytes and cannot change HTML parser state."
case "<?":
return "**`<? … ?>`** contains Go statements and is valid only at an HTML content boundary."
}
return ""
}
func enclosingTag(text []byte, offset int) (string, int, int) {
if offset < 0 || offset > len(text) {
return "", 0, 0
}
open := bytes.LastIndex(text[:offset], []byte("<?"))
if open < 0 {
return "", 0, 0
}
closeRelative := bytes.Index(text[open:], []byte("?>"))
if closeRelative < 0 || open+closeRelative+2 < offset {
return "", 0, 0
}
end := open + closeRelative + 2
marker := "<?"
for _, candidate := range []string{"<?sando", "<?~", "<?=", "<?#"} {
if bytes.HasPrefix(text[open:end], []byte(candidate)) {
marker = candidate
break
}
}
return marker, open, end
}
func (server *Server) definition(request textDocumentPositionParams) any {
document, analysis, ok := server.snapshotDocument(request.TextDocument.URI)
if !ok {
return nil
}
offset, ok := positionToOffset(document.Text, request.Position)
if !ok {
return nil
}
for _, region := range analysis.Regions {
if region.Kind != compiler.AnalysisComponent || offset < region.Offset || offset > region.Offset+region.Length {
continue
}
qualifier, name := referenceAt(region.Text, offset-region.Offset)
if name == "" {
return nil
}
target, found := server.resolveComponent(document.Path, analysis, qualifier, name)
if !found {
return nil
}
server.mu.RLock()
targetDocument, exists := server.snapshot.documents[target.Path]
server.mu.RUnlock()
if !exists {
return nil
}
start := target.Analysis.ComponentOffset
end := start + len(target.Analysis.Signature)
return Location{URI: targetDocument.URI, Range: Range{Start: offsetToPosition(targetDocument.Text, start), End: offsetToPosition(targetDocument.Text, end)}}
}
return nil
}
func (server *Server) resolveComponent(path string, analysis compiler.DocumentAnalysis, qualifier, name string) (componentTarget, bool) {
server.mu.RLock()
snapshot := server.snapshot
server.mu.RUnlock()
for _, target := range snapshot.componentsFor(path, analysis) {
if target.Qualifier == qualifier && target.Name == name {
return target, true
}
}
return componentTarget{}, false
}
func referenceAt(expression string, cursor int) (string, string) {
if cursor < 0 {
cursor = 0
}
if cursor > len(expression) {
cursor = len(expression)
}
if cursor == len(expression) && cursor > 0 {
cursor--
}
for cursor > 0 && cursor < len(expression) && !identifierByte(expression[cursor]) && expression[cursor] != '.' {
cursor--
}
start := cursor
for start > 0 && (identifierByte(expression[start-1]) || expression[start-1] == '.') {
start--
}
end := cursor
for end < len(expression) && (identifierByte(expression[end]) || expression[end] == '.') {
end++
}
reference := strings.Trim(expression[start:end], ".")
parts := strings.Split(reference, ".")
if len(parts) == 1 && validIdentifier(parts[0]) {
return "", parts[0]
}
if len(parts) == 2 && validIdentifier(parts[0]) && validIdentifier(parts[1]) {
return parts[0], parts[1]
}
return "", ""
}
func identifierByte(value byte) bool {
return value == '_' || value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9'
}
func validIdentifier(value string) bool {
if value == "" {
return false
}
for index, r := range value {
if index == 0 && !(r == '_' || unicode.IsLetter(r)) {
return false
}
if index != 0 && !(r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)) {
return false
}
}
return true
}
func (server *Server) documentSymbols(uri string) []documentSymbol {
document, analysis, ok := server.snapshotDocument(uri)
if !ok || analysis.Component == "" {
return []documentSymbol{}
}
documentRange := Range{Start: Position{}, End: offsetToPosition(document.Text, len(document.Text))}
selectionStart := analysis.ComponentOffset
selectionEnd := selectionStart + len(analysis.Component)
children := make([]documentSymbol, 0, len(analysis.Regions))
for index, region := range analysis.Regions {
end := region.Offset + region.Length
name := fmt.Sprintf("%s %d", region.Kind, index+1)
if region.Kind == compiler.AnalysisComponent {
_, componentName := referenceAt(region.Text, 0)
if componentName != "" {
name = "component " + componentName
}
}
rangeValue := Range{Start: offsetToPosition(document.Text, region.Offset), End: offsetToPosition(document.Text, end)}
children = append(children, documentSymbol{Name: name, Detail: string(region.Context), Kind: 13, Range: rangeValue, SelectionRange: rangeValue})
}
return []documentSymbol{{
Name: analysis.Component, Detail: analysis.Signature, Kind: 12,
Range: documentRange,
SelectionRange: Range{Start: offsetToPosition(document.Text, selectionStart), End: offsetToPosition(document.Text, selectionEnd)},
Children: children,
}}
}
func runeEnd(text []byte, offset int) int {
if offset >= len(text) {
return len(text)
}
_, size := utf8.DecodeRune(text[offset:])
if size < 1 {
size = 1
}
return offset + size
}
+174
View File
@@ -0,0 +1,174 @@
// SPDX-License-Identifier: AGPL-3.0-only
package lsp
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"strconv"
"strings"
"sync"
)
const (
maxMessageBytes = 16 << 20
maxHeaderBytes = 64 << 10
maxHeaderLines = 64
)
type rpcMessage struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func validRequestID(id json.RawMessage) bool {
if len(id) == 0 {
return true
}
var value any
if err := json.Unmarshal(id, &value); err != nil {
return false
}
switch value := value.(type) {
case string:
return true
case float64:
return value == math.Trunc(value)
default:
return false
}
}
const (
errParse = -32700
errInvalidRequest = -32600
errMethodNotFound = -32601
errInvalidParams = -32602
errInternal = -32603
errRequestCancelled = -32800
)
type frameReader struct{ reader *bufio.Reader }
func newFrameReader(input io.Reader) *frameReader {
return &frameReader{reader: bufio.NewReaderSize(input, 64<<10)}
}
func (reader *frameReader) read() ([]byte, error) {
contentLength := -1
headerBytes := 0
headerLines := 0
for {
line, err := reader.reader.ReadString('\n')
if err != nil {
return nil, err
}
headerBytes += len(line)
headerLines++
if headerBytes > maxHeaderBytes || headerLines > maxHeaderLines {
return nil, errors.New("LSP headers exceed configured limits")
}
if len(line) > 8<<10 {
return nil, errors.New("LSP header line exceeds 8 KiB")
}
line = strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r")
if line == "" {
break
}
name, value, ok := strings.Cut(line, ":")
if !ok {
return nil, errors.New("malformed LSP header")
}
if strings.EqualFold(strings.TrimSpace(name), "Content-Length") {
if contentLength >= 0 {
return nil, errors.New("duplicate Content-Length header")
}
parsed, parseErr := strconv.Atoi(strings.TrimSpace(value))
if parseErr != nil || parsed < 0 || parsed > maxMessageBytes {
return nil, errors.New("invalid or excessive Content-Length")
}
contentLength = parsed
}
}
if contentLength < 0 {
return nil, errors.New("missing Content-Length header")
}
payload := make([]byte, contentLength)
if _, err := io.ReadFull(reader.reader, payload); err != nil {
return nil, err
}
return payload, nil
}
type frameWriter struct {
mu sync.Mutex
output io.Writer
}
func (writer *frameWriter) write(message rpcMessage) error {
payload, err := json.Marshal(message)
if err != nil {
return err
}
var frame bytes.Buffer
fmt.Fprintf(&frame, "Content-Length: %d\r\n\r\n", len(payload))
frame.Write(payload)
writer.mu.Lock()
defer writer.mu.Unlock()
written, err := writer.output.Write(frame.Bytes())
if err == nil && written != frame.Len() {
return io.ErrShortWrite
}
return err
}
type Position struct {
Line int `json:"line"`
Character int `json:"character"`
}
type Range struct {
Start Position `json:"start"`
End Position `json:"end"`
}
type Location struct {
URI string `json:"uri"`
Range Range `json:"range"`
}
type lspDiagnostic struct {
Range Range `json:"range"`
Severity int `json:"severity,omitempty"`
Code string `json:"code,omitempty"`
Source string `json:"source,omitempty"`
Message string `json:"message"`
}
type textDocumentIdentifier struct {
URI string `json:"uri"`
}
type versionedTextDocumentIdentifier struct {
URI string `json:"uri"`
Version int `json:"version"`
}
type textDocumentPositionParams struct {
TextDocument textDocumentIdentifier `json:"textDocument"`
Position Position `json:"position"`
}
+155
View File
@@ -0,0 +1,155 @@
// SPDX-License-Identifier: AGPL-3.0-only
package lsp
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"testing"
)
type shortWriter struct{}
func (shortWriter) Write(value []byte) (int, error) {
if len(value) == 0 {
return 0, nil
}
return len(value) - 1, nil
}
func TestProtocolFramingRoundTripAndLimits(t *testing.T) {
t.Parallel()
var output bytes.Buffer
writer := &frameWriter{output: &output}
if err := writer.write(rpcMessage{JSONRPC: "2.0", ID: json.RawMessage("1"), Result: map[string]bool{"ok": true}}); err != nil {
t.Fatal(err)
}
payload, err := newFrameReader(&output).read()
if err != nil {
t.Fatal(err)
}
var decoded rpcMessage
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatal(err)
}
if string(decoded.ID) != "1" {
t.Fatalf("response ID = %s", decoded.ID)
}
invalid := "Content-Length: 999999999\r\n\r\n"
if _, err := newFrameReader(strings.NewReader(invalid)).read(); err == nil {
t.Fatal("excessive frame length was accepted")
}
duplicate := "Content-Length: 2\r\nContent-Length: 2\r\n\r\n{}"
if _, err := newFrameReader(strings.NewReader(duplicate)).read(); err == nil {
t.Fatal("duplicate Content-Length was accepted")
}
var excessive bytes.Buffer
for range maxHeaderLines + 1 {
excessive.WriteString("X-Test: value\r\n")
}
excessive.WriteString("Content-Length: 2\r\n\r\n{}")
if _, err := newFrameReader(&excessive).read(); err == nil {
t.Fatal("excessive header count was accepted")
}
if err := (&frameWriter{output: shortWriter{}}).write(rpcMessage{JSONRPC: "2.0", ID: json.RawMessage("1"), Result: true}); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("short writer error = %v, want io.ErrShortWrite", err)
}
}
func TestMalformedJSONProducesProtocolErrorAndContinues(t *testing.T) {
t.Parallel()
input := bytes.NewBuffer(nil)
input.WriteString("Content-Length: 1\r\n\r\n{")
exit, err := json.Marshal(rpcMessage{JSONRPC: "2.0", Method: "exit"})
if err != nil {
t.Fatal(err)
}
fmt.Fprintf(input, "Content-Length: %d\r\n\r\n", len(exit))
input.Write(exit)
var output bytes.Buffer
if err := Run(context.Background(), Options{Input: input, Output: &output}); err != nil {
t.Fatal(err)
}
payload, err := newFrameReader(&output).read()
if err != nil {
t.Fatal(err)
}
var response rpcMessage
if err := json.Unmarshal(payload, &response); err != nil {
t.Fatal(err)
}
if response.Error == nil || response.Error.Code != errParse {
t.Fatalf("parse error response = %#v", response)
}
}
func TestRequestIDValidation(t *testing.T) {
t.Parallel()
for _, id := range []string{`1`, `-2`, `"request"`} {
if !validRequestID(json.RawMessage(id)) {
t.Errorf("valid request ID rejected: %s", id)
}
}
for _, id := range []string{`null`, `true`, `1.5`, `{}`, `[]`, `not-json`} {
if validRequestID(json.RawMessage(id)) {
t.Errorf("invalid request ID accepted: %s", id)
}
}
}
func TestUTF16PositionsWithUnicodeAndCRLF(t *testing.T) {
t.Parallel()
text := []byte("a😀b\r\n雪c\n")
tests := []struct {
offset int
position Position
}{
{offset: 0, position: Position{Line: 0, Character: 0}},
{offset: 1, position: Position{Line: 0, Character: 1}},
{offset: 5, position: Position{Line: 0, Character: 3}},
{offset: 8, position: Position{Line: 1, Character: 0}},
{offset: 11, position: Position{Line: 1, Character: 1}},
}
for _, test := range tests {
if got := offsetToPosition(text, test.offset); got != test.position {
t.Errorf("offsetToPosition(%d) = %#v, want %#v", test.offset, got, test.position)
}
if got, ok := positionToOffset(text, test.position); !ok || got != test.offset {
t.Errorf("positionToOffset(%#v) = %d, %v; want %d, true", test.position, got, ok, test.offset)
}
}
if _, ok := positionToOffset(text, Position{Line: 0, Character: 2}); ok {
t.Fatal("position inside UTF-16 surrogate pair was accepted")
}
}
func FuzzFrameReaderNeverPanics(f *testing.F) {
f.Add([]byte("Content-Length: 2\r\n\r\n{}"))
f.Add([]byte("Content-Length: nope\r\n\r\n"))
f.Fuzz(func(t *testing.T, data []byte) {
if len(data) > 64<<10 {
t.Skip()
}
_, _ = newFrameReader(bytes.NewReader(data)).read()
})
}
func FuzzDocumentPositionNeverPanics(f *testing.F) {
f.Add("hello 😀\r\nworld", 0, 7)
f.Add("雪", 0, 1)
f.Fuzz(func(t *testing.T, text string, line, character int) {
if len(text) > 64<<10 || line < -10000 || line > 10000 || character < -10000 || character > 100000 {
t.Skip()
}
offset, ok := positionToOffset([]byte(text), Position{Line: line, Character: character})
if ok {
_ = offsetToPosition([]byte(text), offset)
}
})
}
+64
View File
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: AGPL-3.0-only
package lsp
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
func TestLanguageServerSourceHasNoExecutionNetworkOrWriteCapability(t *testing.T) {
t.Parallel()
entries, err := os.ReadDir(".")
if err != nil {
t.Fatal(err)
}
forbiddenImports := map[string]bool{
"net": true, "net/http": true, "net/rpc": true,
"os/exec": true, "syscall": true,
}
forbiddenOSCalls := map[string]bool{
"Create": true, "CreateTemp": true, "Mkdir": true, "MkdirAll": true,
"OpenFile": true, "Remove": true, "RemoveAll": true, "Rename": true,
"WriteFile": true, "Chmod": true, "Chown": true,
}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".go" || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
parsed, err := parser.ParseFile(token.NewFileSet(), entry.Name(), nil, 0)
if err != nil {
t.Fatal(err)
}
for _, imported := range parsed.Imports {
path, err := strconv.Unquote(imported.Path.Value)
if err != nil {
t.Fatal(err)
}
if forbiddenImports[path] {
t.Errorf("%s imports forbidden capability %s", entry.Name(), path)
}
}
ast.Inspect(parsed, func(node ast.Node) bool {
call, ok := node.(*ast.CallExpr)
if !ok {
return true
}
selector, ok := call.Fun.(*ast.SelectorExpr)
if !ok || !forbiddenOSCalls[selector.Sel.Name] {
return true
}
identifier, ok := selector.X.(*ast.Ident)
if ok && identifier.Name == "os" {
t.Errorf("%s calls forbidden filesystem mutation os.%s", entry.Name(), selector.Sel.Name)
}
return true
})
}
}
+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)
}
+317
View File
@@ -0,0 +1,317 @@
// SPDX-License-Identifier: AGPL-3.0-only
package lsp
import (
"bytes"
"context"
"encoding/json"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"gamertan.com/sandwich-hime/internal/compiler"
)
type protocolClient struct {
input *io.PipeWriter
output *frameReader
nextID int
lastPayload []byte
}
func newProtocolClient(t *testing.T, root string) (*protocolClient, <-chan error) {
t.Helper()
serverInput, clientInput := io.Pipe()
clientOutput, serverOutput := io.Pipe()
done := make(chan error, 1)
go func() {
done <- Run(context.Background(), Options{Input: serverInput, Output: serverOutput, LogOutput: io.Discard, Debounce: 10 * time.Millisecond})
_ = serverOutput.Close()
}()
client := &protocolClient{input: clientInput, output: newFrameReader(clientOutput)}
response := client.call(t, "initialize", map[string]any{"rootUri": pathToURI(root)})
if response.Error != nil {
t.Fatalf("initialize: %#v", response.Error)
}
client.notify(t, "initialized", map[string]any{})
return client, done
}
func (client *protocolClient) send(t *testing.T, message rpcMessage) {
t.Helper()
payload, err := json.Marshal(message)
if err != nil {
t.Fatal(err)
}
frame := append([]byte("Content-Length: "+itoa(len(payload))+"\r\n\r\n"), payload...)
if _, err := client.input.Write(frame); err != nil {
t.Fatal(err)
}
}
func (client *protocolClient) call(t *testing.T, method string, params any) rpcMessage {
t.Helper()
client.nextID++
id := client.nextID
payload, err := json.Marshal(params)
if err != nil {
t.Fatal(err)
}
client.send(t, rpcMessage{JSONRPC: "2.0", ID: json.RawMessage(itoa(id)), Method: method, Params: payload})
for {
message := client.read(t)
if string(message.ID) == itoa(id) {
return message
}
}
}
func (client *protocolClient) notify(t *testing.T, method string, params any) {
t.Helper()
payload, err := json.Marshal(params)
if err != nil {
t.Fatal(err)
}
client.send(t, rpcMessage{JSONRPC: "2.0", Method: method, Params: payload})
}
func (client *protocolClient) read(t *testing.T) rpcMessage {
t.Helper()
type result struct {
message rpcMessage
payload []byte
err error
}
ready := make(chan result, 1)
go func() {
payload, err := client.output.read()
if err != nil {
ready <- result{err: err}
return
}
var message rpcMessage
err = json.Unmarshal(payload, &message)
ready <- result{message: message, payload: payload, err: err}
}()
select {
case got := <-ready:
if got.err != nil {
t.Fatal(got.err)
}
client.lastPayload = got.payload
return got.message
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for language-server response")
return rpcMessage{}
}
}
func (client *protocolClient) waitDiagnostics(t *testing.T, uri string, wantCode string) []lspDiagnostic {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
message := client.read(t)
if message.Method != "textDocument/publishDiagnostics" {
continue
}
var published struct {
URI string `json:"uri"`
Diagnostics []lspDiagnostic `json:"diagnostics"`
}
if json.Unmarshal(message.Params, &published) != nil || published.URI != uri {
continue
}
if wantCode == "" {
return published.Diagnostics
}
for _, item := range published.Diagnostics {
if item.Code == wantCode {
return published.Diagnostics
}
}
}
t.Fatalf("timed out waiting for %s diagnostic", wantCode)
return nil
}
func TestServerOverlayFeaturesAndNoWrites(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "go.mod"), "module example.test/project\n\ngo 1.25\n")
homePath := filepath.Join(root, "home.sando")
badgePath := filepath.Join(root, "cards", "badge.sando")
writeTestFile(t, homePath, "<?sando go\npackage views\nfunc Home(visitor string)\n?>\n<p><?= visitor ?></p>\n")
writeTestFile(t, badgePath, "<?sando go\npackage cards\nfunc Badge(label string)\n?>\n<strong><?= label ?></strong>\n")
client, done := newProtocolClient(t, root)
homeURI := pathToURI(homePath)
client.waitDiagnostics(t, homeURI, "")
overlay := "<?sando go\npackage views\nimport \"example.test/project/cards\"\nfunc Home(visitor string)\n?>\n<p>😀 <?= visitor ?></p>\n<?~ cards.Badge(\"new\") ?>\n"
client.notify(t, "textDocument/didOpen", map[string]any{"textDocument": map[string]any{"uri": homeURI, "languageId": "sando", "version": 1, "text": overlay}})
if diagnostics := client.waitDiagnostics(t, homeURI, ""); len(diagnostics) != 0 {
t.Fatalf("valid overlay diagnostics = %#v", diagnostics)
}
completionOffset := strings.Index(overlay, "cards.Badge") + len("cards.B")
completion := client.call(t, "textDocument/completion", textDocumentPositionParams{TextDocument: textDocumentIdentifier{URI: homeURI}, Position: offsetToPosition([]byte(overlay), completionOffset)})
assertJSONContains(t, completion.Result, `"label":"cards.Badge"`)
definitionOffset := strings.Index(overlay, "Badge") + 2
definition := client.call(t, "textDocument/definition", textDocumentPositionParams{TextDocument: textDocumentIdentifier{URI: homeURI}, Position: offsetToPosition([]byte(overlay), definitionOffset)})
assertJSONContains(t, definition.Result, pathToURI(badgePath))
hoverOffset := strings.Index(overlay, "visitor ?></p>") + 2
hover := client.call(t, "textDocument/hover", textDocumentPositionParams{TextDocument: textDocumentIdentifier{URI: homeURI}, Position: offsetToPosition([]byte(overlay), hoverOffset)})
assertJSONContains(t, hover.Result, "html-text")
symbols := client.call(t, "textDocument/documentSymbol", map[string]any{"textDocument": map[string]string{"uri": homeURI}})
assertJSONContains(t, symbols.Result, `"name":"Home"`)
if err := os.Remove(badgePath); err != nil {
t.Fatal(err)
}
client.notify(t, "workspace/didChangeWatchedFiles", map[string]any{"changes": []map[string]any{{"uri": pathToURI(badgePath), "type": 3}}})
client.waitDiagnostics(t, pathToURI(badgePath), "")
completion = client.call(t, "textDocument/completion", textDocumentPositionParams{TextDocument: textDocumentIdentifier{URI: homeURI}, Position: offsetToPosition([]byte(overlay), completionOffset)})
if payload, _ := json.Marshal(completion.Result); strings.Contains(string(payload), `"label":"cards.Badge"`) {
t.Fatalf("deleted component remained in completion index: %s", payload)
}
broken := strings.Replace(overlay, "<?~ cards.Badge(\"new\") ?>", "<div>", 1)
client.notify(t, "textDocument/didChange", map[string]any{
"textDocument": map[string]any{"uri": homeURI, "version": 2},
"contentChanges": []map[string]string{{"text": broken}},
})
client.waitDiagnostics(t, homeURI, "HIM1311")
if _, err := os.Stat(homePath + ".go"); !os.IsNotExist(err) {
t.Fatalf("language server wrote generated output: %v", err)
}
shutdown := client.call(t, "shutdown", map[string]any{})
if shutdown.Error != nil {
t.Fatalf("shutdown: %#v", shutdown.Error)
}
if !bytes.Contains(client.lastPayload, []byte(`"result":null`)) {
t.Fatalf("shutdown response omitted JSON-RPC null result: %s", client.lastPayload)
}
client.notify(t, "exit", map[string]any{})
_ = client.input.Close()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(3 * time.Second):
t.Fatal("language server did not exit")
}
}
func TestOverlayHonorsNestedModuleAndSymlinkBoundaries(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "go.mod"), "module example.test/root\n")
nestedPath := filepath.Join(root, "nested", "view.sando")
writeTestFile(t, filepath.Join(root, "nested", "go.mod"), "module example.test/nested\n")
writeTestFile(t, nestedPath, "<?sando go\npackage nested\nfunc View()\n?>\n<p>no</p>\n")
server := &Server{root: root, overlays: make(map[string]document)}
server.updateOverlay(pathToURI(nestedPath), 1, "ignored", true)
if len(server.overlays) != 0 {
t.Fatalf("nested-module overlay was accepted: %#v", server.overlays)
}
if runtime.GOOS != "windows" {
realDirectory := filepath.Join(root, "real")
if err := os.Mkdir(realDirectory, 0o700); err != nil {
t.Fatal(err)
}
linkDirectory := filepath.Join(root, "linked")
if err := os.Symlink(realDirectory, linkDirectory); err != nil {
t.Fatal(err)
}
linkedPath := filepath.Join(linkDirectory, "view.sando")
server.updateOverlay(pathToURI(linkedPath), 1, "ignored", true)
if len(server.overlays) != 0 {
t.Fatalf("symlink overlay was accepted: %#v", server.overlays)
}
}
}
func TestServerRejectsMultipleRootsAndCanceledRequest(t *testing.T) {
root := t.TempDir()
server := &Server{initialized: true, snapshot: workspaceSnapshot{documents: map[string]document{}, analyses: map[string]compiler.DocumentAnalysis{}}}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, rpcErr := server.handleRequest(ctx, "textDocument/completion", json.RawMessage(`{}`))
if rpcErr == nil || rpcErr.Code != errRequestCancelled {
t.Fatalf("canceled request error = %#v", rpcErr)
}
input, writer := io.Pipe()
reader, serverOutput := io.Pipe()
done := make(chan error, 1)
go func() { done <- Run(context.Background(), Options{Input: input, Output: serverOutput}) }()
client := &protocolClient{input: writer, output: newFrameReader(reader)}
response := client.call(t, "initialize", map[string]any{"workspaceFolders": []map[string]string{{"uri": pathToURI(root)}, {"uri": pathToURI(root)}}})
if response.Error == nil || response.Error.Code != errInvalidParams {
t.Fatalf("multiple-root response = %#v", response)
}
client.notify(t, "exit", map[string]any{})
_ = writer.Close()
<-done
}
func TestReindexCountsOpenOverlaysInWorkspaceLimit(t *testing.T) {
root := t.TempDir()
server := &Server{
root: root,
overlays: make(map[string]document, maxWorkspaceFiles+1),
snapshot: workspaceSnapshot{documents: map[string]document{}, analyses: map[string]compiler.DocumentAnalysis{}},
writer: &frameWriter{output: io.Discard},
logs: io.Discard,
}
for index := range maxWorkspaceFiles + 1 {
path := filepath.Join(root, "overlay-"+itoa(index)+".sando")
server.overlays[path] = document{URI: pathToURI(path), Path: path, Text: []byte("<?sando go\npackage views\nfunc View()\n?>\n"), Open: true}
}
err := server.reindex(context.Background())
if err == nil || !strings.Contains(err.Error(), "more than 10000") {
t.Fatalf("reindex overlay limit error = %v", err)
}
}
func writeTestFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
func assertJSONContains(t *testing.T, value any, marker string) {
t.Helper()
payload, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(payload), marker) {
t.Fatalf("JSON %s does not contain %q", payload, marker)
}
}
func itoa(value int) string {
if value == 0 {
return "0"
}
var digits [20]byte
index := len(digits)
for value > 0 {
index--
digits[index] = byte('0' + value%10)
value /= 10
}
return string(digits[index:])
}
+466
View File
@@ -0,0 +1,466 @@
// SPDX-License-Identifier: AGPL-3.0-only
package lsp
import (
"context"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"unicode/utf16"
"unicode/utf8"
"gamertan.com/sandwich-hime/internal/compiler"
)
const (
maxDocumentBytes = 16 << 20
maxWorkspaceBytes = 64 << 20
maxWorkspaceFiles = 10000
)
type document struct {
URI string
Path string
Text []byte
Version int
Open bool
}
type workspaceSnapshot struct {
documents map[string]document
analyses map[string]compiler.DocumentAnalysis
moduleRoot string
modulePath string
}
func (server *Server) reindex(ctx context.Context) error {
server.mu.RLock()
root := server.root
overlays := make(map[string]document, len(server.overlays))
for path, item := range server.overlays {
overlays[path] = item
}
server.mu.RUnlock()
if root == "" {
return nil
}
paths, discoveryDiagnostics := compiler.DiscoverSources(ctx, []string{root})
if err := ctx.Err(); err != nil {
return err
}
if len(paths) > maxWorkspaceFiles {
return fmt.Errorf("workspace contains more than %d .sando files", maxWorkspaceFiles)
}
documents := make(map[string]document, len(paths)+len(overlays))
total := 0
for _, path := range paths {
absolute, err := filepath.Abs(path)
if err != nil {
continue
}
absolute = filepath.Clean(absolute)
if overlay, ok := overlays[absolute]; ok {
documents[absolute] = overlay
total += len(overlay.Text)
continue
}
info, err := os.Lstat(absolute)
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() > maxDocumentBytes {
continue
}
content, err := os.ReadFile(absolute)
if err != nil || len(content) > maxDocumentBytes {
continue
}
documents[absolute] = document{URI: pathToURI(absolute), Path: absolute, Text: content}
total += len(content)
if total > maxWorkspaceBytes {
return fmt.Errorf("workspace .sando sources exceed %d bytes", maxWorkspaceBytes)
}
}
for path, overlay := range overlays {
if _, ok := documents[path]; ok {
continue
}
if overlay.Open && editorPathAllowed(root, path) {
documents[path] = overlay
total += len(overlay.Text)
}
}
if len(documents) > maxWorkspaceFiles {
return fmt.Errorf("workspace contains more than %d .sando files", maxWorkspaceFiles)
}
if total > maxWorkspaceBytes {
return fmt.Errorf("workspace .sando sources exceed %d bytes", maxWorkspaceBytes)
}
inputs := make([]compiler.SourceInput, 0, len(documents))
for _, item := range documents {
inputs = append(inputs, compiler.SourceInput{Path: item.Path, Source: item.Text})
}
analysed := compiler.AnalyzeSources(ctx, inputs)
if err := ctx.Err(); err != nil {
return err
}
analyses := make(map[string]compiler.DocumentAnalysis, len(analysed))
for _, item := range analysed {
analyses[filepath.Clean(item.Path)] = item
}
moduleRoot, modulePath := moduleIdentity(root)
server.mu.Lock()
previous := server.snapshot.documents
server.snapshot = workspaceSnapshot{documents: documents, analyses: analyses, moduleRoot: moduleRoot, modulePath: modulePath}
server.mu.Unlock()
all := make(map[string]bool, len(previous)+len(documents))
for path := range previous {
all[path] = true
}
for path := range documents {
all[path] = true
}
ordered := make([]string, 0, len(all))
for path := range all {
ordered = append(ordered, path)
}
sort.Strings(ordered)
for _, path := range ordered {
item, exists := documents[path]
uri := pathToURI(path)
diagnostics := make([]lspDiagnostic, 0)
if exists {
uri = item.URI
for _, diagnostic := range analyses[path].Diagnostics {
diagnostics = append(diagnostics, diagnosticToLSP(item.Text, diagnostic))
}
}
if err := server.notify("textDocument/publishDiagnostics", struct {
URI string `json:"uri"`
Diagnostics []lspDiagnostic `json:"diagnostics"`
}{URI: uri, Diagnostics: diagnostics}); err != nil {
return err
}
}
if len(discoveryDiagnostics) != 0 {
server.log("workspace discovery reported boundary diagnostics", len(discoveryDiagnostics))
}
server.log("analysis completed", len(documents))
return nil
}
func (server *Server) scheduleReindex(delay bool) {
server.mu.Lock()
if server.analysisCancel != nil {
server.analysisCancel()
}
if server.analysisTimer != nil {
server.analysisTimer.Stop()
}
generation := server.analysisGeneration + 1
server.analysisGeneration = generation
wait := server.debounce
if !delay {
wait = 0
}
server.analysisTimer = server.afterFunc(wait, func() {
ctx, cancel := context.WithCancel(server.context)
server.mu.Lock()
if server.analysisGeneration != generation {
server.mu.Unlock()
cancel()
return
}
server.analysisWait.Add(1)
server.analysisCancel = cancel
server.mu.Unlock()
defer server.analysisWait.Done()
err := server.reindex(ctx)
cancel()
server.mu.Lock()
if server.analysisGeneration == generation {
server.analysisCancel = nil
}
server.mu.Unlock()
if err != nil && !errors.Is(err, context.Canceled) {
server.log("analysis failed", 1)
}
})
server.mu.Unlock()
}
func (server *Server) snapshotDocument(uri string) (document, compiler.DocumentAnalysis, bool) {
path, err := fileURIToPath(uri)
if err != nil {
return document{}, compiler.DocumentAnalysis{}, false
}
server.mu.RLock()
defer server.mu.RUnlock()
item, ok := server.snapshot.documents[path]
if !ok {
return document{}, compiler.DocumentAnalysis{}, false
}
return item, server.snapshot.analyses[path], true
}
func moduleIdentity(root string) (string, string) {
candidate := filepath.Join(root, "go.mod")
content, err := os.ReadFile(candidate)
if err != nil || len(content) > 1<<20 {
return "", ""
}
for _, line := range strings.Split(string(content), "\n") {
fields := strings.Fields(strings.TrimSpace(line))
if len(fields) == 2 && fields[0] == "module" {
return filepath.Clean(root), fields[1]
}
}
return "", ""
}
func (snapshot workspaceSnapshot) packageImportPath(directory string) string {
if snapshot.moduleRoot == "" || snapshot.modulePath == "" || !withinRoot(snapshot.moduleRoot, directory) {
return ""
}
relative, err := filepath.Rel(snapshot.moduleRoot, directory)
if err != nil || relative == "." {
return snapshot.modulePath
}
return strings.TrimSuffix(snapshot.modulePath, "/") + "/" + filepath.ToSlash(relative)
}
func (snapshot workspaceSnapshot) componentsFor(path string, analysis compiler.DocumentAnalysis) []componentTarget {
directory := filepath.Dir(path)
var targets []componentTarget
for targetPath, targetAnalysis := range snapshot.analyses {
if targetAnalysis.Component == "" {
continue
}
targetDirectory := filepath.Dir(targetPath)
if targetDirectory == directory && targetAnalysis.Package == analysis.Package {
targets = append(targets, componentTarget{Name: targetAnalysis.Component, Signature: targetAnalysis.Signature, Path: targetPath, Analysis: targetAnalysis})
continue
}
importPath := snapshot.packageImportPath(targetDirectory)
for _, imported := range analysis.Imports {
if imported.Path != importPath || imported.Alias == "_" || imported.Alias == "." {
continue
}
alias := imported.Alias
if alias == "" {
alias = targetAnalysis.Package
}
targets = append(targets, componentTarget{Qualifier: alias, Name: targetAnalysis.Component, Signature: targetAnalysis.Signature, Path: targetPath, Analysis: targetAnalysis})
}
}
sort.SliceStable(targets, func(i, j int) bool {
return targets[i].Label() < targets[j].Label()
})
return targets
}
type componentTarget struct {
Qualifier string
Name string
Signature string
Path string
Analysis compiler.DocumentAnalysis
}
func (target componentTarget) Label() string {
if target.Qualifier == "" {
return target.Name
}
return target.Qualifier + "." + target.Name
}
func withinRoot(root, path string) bool {
relative, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)
}
func editorPathAllowed(root, path string) bool {
if filepath.Ext(path) != ".sando" || !withinRoot(root, path) {
return false
}
relative, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
if err != nil {
return false
}
current := filepath.Clean(root)
parts := strings.Split(relative, string(filepath.Separator))
for index, part := range parts {
if part == "" || part == "." {
continue
}
if index < len(parts)-1 && (part == ".git" || part == ".hg" || part == ".svn" || part == "vendor") {
return false
}
current = filepath.Join(current, part)
info, statErr := os.Lstat(current)
if statErr == nil && info.Mode()&os.ModeSymlink != 0 {
return false
}
if statErr != nil && !os.IsNotExist(statErr) {
return false
}
if index < len(parts)-1 && current != filepath.Clean(root) {
moduleInfo, moduleErr := os.Lstat(filepath.Join(current, "go.mod"))
if moduleErr == nil || moduleInfo != nil {
return false
}
if moduleErr != nil && !os.IsNotExist(moduleErr) {
return false
}
}
}
return true
}
func fileURIToPath(value string) (string, error) {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme != "file" || (parsed.Host != "" && parsed.Host != "localhost") {
return "", errors.New("only local file URIs are supported")
}
path, err := url.PathUnescape(parsed.EscapedPath())
if err != nil {
return "", err
}
if runtime.GOOS == "windows" && len(path) >= 3 && path[0] == '/' && path[2] == ':' {
path = path[1:]
}
absolute, err := filepath.Abs(filepath.FromSlash(path))
if err != nil {
return "", err
}
return filepath.Clean(absolute), nil
}
func pathToURI(path string) string {
absolute, err := filepath.Abs(path)
if err != nil {
absolute = filepath.Clean(path)
}
slashed := filepath.ToSlash(absolute)
if runtime.GOOS == "windows" && !strings.HasPrefix(slashed, "/") {
slashed = "/" + slashed
}
return (&url.URL{Scheme: "file", Path: slashed}).String()
}
func offsetToPosition(text []byte, offset int) Position {
if offset < 0 {
offset = 0
}
if offset > len(text) {
offset = len(text)
}
line, character := 0, 0
for index := 0; index < offset; {
if text[index] == '\n' {
line++
character = 0
index++
continue
}
r, size := utf8.DecodeRune(text[index:])
if r == utf8.RuneError && size == 1 {
character++
index++
continue
}
character += len(utf16.Encode([]rune{r}))
index += size
}
return Position{Line: line, Character: character}
}
func positionToOffset(text []byte, position Position) (int, bool) {
if position.Line < 0 || position.Character < 0 {
return 0, false
}
line := 0
start := 0
for start < len(text) && line < position.Line {
if text[start] == '\n' {
line++
}
start++
}
if line != position.Line {
return 0, false
}
units := 0
for index := start; index < len(text) && text[index] != '\n'; {
if units == position.Character {
return index, true
}
r, size := utf8.DecodeRune(text[index:])
if r == utf8.RuneError && size == 1 {
units++
index++
} else {
units += len(utf16.Encode([]rune{r}))
index += size
}
if units > position.Character {
return 0, false
}
}
if units == position.Character {
index := start
for index < len(text) && text[index] != '\n' {
index++
}
return index, true
}
return 0, false
}
func compilerPositionOffset(text []byte, line, column int) int {
if line < 1 {
line = 1
}
if column < 1 {
column = 1
}
start := 0
for current := 1; current < line && start < len(text); current++ {
newline := strings.IndexByte(string(text[start:]), '\n')
if newline < 0 {
return len(text)
}
start += newline + 1
}
offset := start + column - 1
if offset > len(text) {
offset = len(text)
}
return offset
}
func diagnosticToLSP(text []byte, diagnostic compiler.Diagnostic) lspDiagnostic {
startOffset := compilerPositionOffset(text, diagnostic.Line, diagnostic.Column)
endOffset := startOffset
if endOffset < len(text) {
_, size := utf8.DecodeRune(text[endOffset:])
if size < 1 {
size = 1
}
endOffset += size
}
severity := 1
if diagnostic.Severity == compiler.SeverityWarning {
severity = 2
}
return lspDiagnostic{
Range: Range{Start: offsetToPosition(text, startOffset), End: offsetToPosition(text, endOffset)},
Severity: severity, Code: diagnostic.Code, Source: "himesan", Message: diagnostic.Message,
}
}