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
+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,
}
}