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
+163
View File
@@ -0,0 +1,163 @@
// SPDX-License-Identifier: AGPL-3.0-only
package compiler
import (
"context"
"fmt"
"path/filepath"
"sort"
)
// SourceInput supplies one in-memory .sando document for editor analysis.
type SourceInput struct {
Path string
Source []byte
}
// AnalysisImport describes one import already present in a .sando header.
type AnalysisImport struct {
Alias string `json:"alias,omitempty"`
Path string `json:"path"`
}
// AnalysisRegionKind identifies an author-visible template region.
type AnalysisRegionKind string
const (
AnalysisStatement AnalysisRegionKind = "statement"
AnalysisExpression AnalysisRegionKind = "expression"
AnalysisComponent AnalysisRegionKind = "component"
AnalysisComment AnalysisRegionKind = "comment"
)
// AnalysisRegion describes a Hime-san tag body using zero-based byte offsets.
// Line and Column retain the compiler's one-based byte-coordinate convention;
// protocol adapters convert them to UTF-16 where required.
type AnalysisRegion struct {
Kind AnalysisRegionKind `json:"kind"`
Text string `json:"text,omitempty"`
Context Context `json:"context,omitempty"`
Offset int `json:"offset"`
Length int `json:"length"`
Line int `json:"line"`
Column int `json:"column"`
}
// DocumentAnalysis is the compiler-owned semantic description consumed by
// read-only tools such as the language server.
type DocumentAnalysis struct {
Path string `json:"path"`
Package string `json:"package,omitempty"`
Component string `json:"component,omitempty"`
TypeParams string `json:"type_params,omitempty"`
Params string `json:"params,omitempty"`
Signature string `json:"signature,omitempty"`
ComponentOffset int `json:"component_offset,omitempty"`
ComponentLine int `json:"component_line,omitempty"`
ComponentColumn int `json:"component_column,omitempty"`
Imports []AnalysisImport `json:"imports,omitempty"`
Regions []AnalysisRegion `json:"regions,omitempty"`
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
}
// AnalyzeSources applies the normal parser, HTML-context analyzer, trust
// audit, backend validation, duplicate-component checks, and statically
// knowable cycle checks to an in-memory source set. It performs no I/O.
func AnalyzeSources(ctx context.Context, inputs []SourceInput) []DocumentAnalysis {
ordered := append([]SourceInput(nil), inputs...)
sort.SliceStable(ordered, func(i, j int) bool {
return filepath.Clean(ordered[i].Path) < filepath.Clean(ordered[j].Path)
})
analyses := make([]DocumentAnalysis, 0, len(ordered))
compiled := make([]CompiledFile, 0, len(ordered))
for _, input := range ordered {
if err := ctx.Err(); err != nil {
analyses = append(analyses, DocumentAnalysis{
Path: filepath.Clean(input.Path),
Diagnostics: []Diagnostic{diagnostic(input.Path, sourcePosition{Line: 1, Column: 1}, "HIM2001", "operation canceled: "+err.Error())},
})
continue
}
analysis, output := analyzeSource(input.Path, input.Source)
analyses = append(analyses, analysis)
if output.Code != nil {
compiled = append(compiled, output)
}
}
byPath := make(map[string][]Diagnostic)
for _, item := range detectComponentCycles(compiled) {
path := filepath.Clean(item.Path)
byPath[path] = append(byPath[path], item)
}
for index := range analyses {
path := filepath.Clean(analyses[index].Path)
analyses[index].Diagnostics = append(analyses[index].Diagnostics, byPath[path]...)
sortDiagnostics(analyses[index].Diagnostics)
}
return analyses
}
func analyzeSource(path string, source []byte) (DocumentAnalysis, CompiledFile) {
cleanPath := filepath.Clean(path)
analysis := DocumentAnalysis{Path: cleanPath}
file, diagnostics := parseSource(cleanPath, source)
if file == nil {
sortDiagnostics(diagnostics)
analysis.Diagnostics = diagnostics
return analysis, CompiledFile{}
}
analysis.Package = file.Package
analysis.Component = file.Name
analysis.TypeParams = file.TypeParams
analysis.Params = file.Params
analysis.Signature = fmt.Sprintf("func %s%s%s", file.Name, file.TypeParams, file.Params)
analysis.ComponentOffset = file.FunctionPos.Offset
analysis.ComponentLine = file.FunctionPos.Line
analysis.ComponentColumn = file.FunctionPos.Column
for _, imported := range file.Imports {
analysis.Imports = append(analysis.Imports, AnalysisImport{Alias: imported.Alias, Path: imported.Path})
}
diagnostics = append(diagnostics, analyzeContexts(file)...)
diagnostics = append(diagnostics, auditTrustCalls(file)...)
for _, node := range file.Nodes {
kind := AnalysisRegionKind("")
switch node.Kind {
case nodeStatement:
kind = AnalysisStatement
case nodeExpression:
kind = AnalysisExpression
case nodeComponent:
kind = AnalysisComponent
case nodeComment:
kind = AnalysisComment
default:
continue
}
analysis.Regions = append(analysis.Regions, AnalysisRegion{
Kind: kind, Text: node.Text, Context: node.Context,
Offset: node.Pos.Offset, Length: len(node.Text),
Line: node.Pos.Line, Column: node.Pos.Column,
})
}
if hasErrors(diagnostics) {
sortDiagnostics(diagnostics)
analysis.Diagnostics = diagnostics
return analysis, CompiledFile{}
}
code, backendDiagnostics := generateGo(file)
diagnostics = append(diagnostics, backendDiagnostics...)
sortDiagnostics(diagnostics)
analysis.Diagnostics = diagnostics
if hasErrors(diagnostics) {
return analysis, CompiledFile{}
}
return analysis, CompiledFile{
SourcePath: cleanPath,
OutputPath: cleanPath + ".go",
Package: file.Package,
Component: file.Name,
Code: code,
source: file,
}
}