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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAnalyzeSourcesUsesCompilerSemanticsWithoutIO(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := t.TempDir()
|
||||
firstPath := filepath.Join(directory, "first.sando")
|
||||
secondPath := filepath.Join(directory, "second.sando")
|
||||
first := []byte("<?sando go\npackage views\nfunc First(name string)\n?>\n<div><?= name ?><?~ Second() ?></div>\n")
|
||||
second := []byte("<?sando go\npackage views\nfunc Second()\n?>\n<section><?~ First(\"again\") ?></section>\n")
|
||||
analyses := AnalyzeSources(context.Background(), []SourceInput{{Path: firstPath, Source: first}, {Path: secondPath, Source: second}})
|
||||
if len(analyses) != 2 {
|
||||
t.Fatalf("analysis count = %d, want 2", len(analyses))
|
||||
}
|
||||
for _, analysis := range analyses {
|
||||
if analysis.Component == "" || analysis.Signature == "" || analysis.ComponentLine < 1 {
|
||||
t.Fatalf("missing component metadata: %#v", analysis)
|
||||
}
|
||||
if !hasDiagnosticCode(analysis.Diagnostics, "HIM1501") {
|
||||
t.Fatalf("cycle diagnostic missing for %s: %#v", analysis.Component, analysis.Diagnostics)
|
||||
}
|
||||
if _, err := os.Stat(analysis.Path + ".go"); !os.IsNotExist(err) {
|
||||
t.Fatalf("analysis wrote generated output: %v", err)
|
||||
}
|
||||
}
|
||||
if analyses[0].Regions[0].Context != ContextHTMLText {
|
||||
t.Fatalf("expression context = %q, want %q", analyses[0].Regions[0].Context, ContextHTMLText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeSourcesDuplicateAndMalformedDocuments(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := t.TempDir()
|
||||
duplicate := "<?sando go\npackage views\nfunc Card()\n?>\n<p>card</p>\n"
|
||||
malformed := "<?sando go\npackage views\nfunc Broken()\n?>\n<div>\x00"
|
||||
analyses := AnalyzeSources(context.Background(), []SourceInput{
|
||||
{Path: filepath.Join(directory, "a.sando"), Source: []byte(duplicate)},
|
||||
{Path: filepath.Join(directory, "b.sando"), Source: []byte(duplicate)},
|
||||
{Path: filepath.Join(directory, "broken.sando"), Source: []byte(malformed)},
|
||||
})
|
||||
if !hasDiagnosticCode(analyses[0].Diagnostics, "HIM1500") || !hasDiagnosticCode(analyses[1].Diagnostics, "HIM1500") {
|
||||
t.Fatalf("duplicate diagnostics missing: %#v", analyses)
|
||||
}
|
||||
if !hasDiagnosticCode(analyses[2].Diagnostics, "HIM1002") {
|
||||
t.Fatalf("NUL diagnostic missing: %#v", analyses[2].Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverSourcesOmitsGeneratedFreshnessButKeepsBoundaries(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/root\n\ngo 1.25\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "orphan.sando.go"), []byte(generatedPrefix+"\npackage root\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nested := filepath.Join(root, "nested")
|
||||
if err := os.Mkdir(nested, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(nested, "go.mod"), []byte("module example.test/nested\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(nested, "hidden.sando"), []byte("ignored"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths, diagnostics := DiscoverSources(context.Background(), []string{root})
|
||||
if len(paths) != 0 {
|
||||
t.Fatalf("discovered nested source: %v", paths)
|
||||
}
|
||||
for _, item := range diagnostics {
|
||||
if item.Code == "HIM2014" {
|
||||
t.Fatalf("editor discovery reported generated freshness: %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasDiagnosticCode(diagnostics []Diagnostic, code string) bool {
|
||||
for _, item := range diagnostics {
|
||||
if item.Code == code || strings.HasPrefix(item.Code, code) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -23,6 +23,17 @@ var excludedDirectories = map[string]bool{
|
||||
}
|
||||
|
||||
func discover(ctx context.Context, paths []string) ([]string, []Diagnostic) {
|
||||
return discoverWithOptions(ctx, paths, true)
|
||||
}
|
||||
|
||||
// DiscoverSources finds .sando sources using the same filesystem, symlink,
|
||||
// nested-module, VCS, and vendor boundaries as Generate and Check. It omits
|
||||
// generated-output inspection because editor analysis does not own freshness.
|
||||
func DiscoverSources(ctx context.Context, paths []string) ([]string, []Diagnostic) {
|
||||
return discoverWithOptions(ctx, paths, false)
|
||||
}
|
||||
|
||||
func discoverWithOptions(ctx context.Context, paths []string, inspectGenerated bool) ([]string, []Diagnostic) {
|
||||
if len(paths) == 0 {
|
||||
paths = []string{"."}
|
||||
}
|
||||
@@ -121,6 +132,9 @@ func discover(ctx context.Context, paths []string) ([]string, []Diagnostic) {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(entry.Name(), ".sando.go") {
|
||||
if !inspectGenerated {
|
||||
return nil
|
||||
}
|
||||
entryInfo, statErr := entry.Info()
|
||||
if statErr != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(path, sourcePosition{Line: 1, Column: 1}, "HIM2013", "cannot inspect possible generated output: "+statErr.Error()))
|
||||
|
||||
+12
-11
@@ -59,17 +59,18 @@ type sourcePosition struct {
|
||||
}
|
||||
|
||||
type sourceFile struct {
|
||||
Path string
|
||||
Mapping string
|
||||
Package string
|
||||
Name string
|
||||
TypeParams string
|
||||
Params string
|
||||
Imports []sourceImport
|
||||
Nodes []rendererNode
|
||||
Source []byte
|
||||
HeaderEnd int
|
||||
AST *ast.File
|
||||
Path string
|
||||
Mapping string
|
||||
Package string
|
||||
Name string
|
||||
TypeParams string
|
||||
Params string
|
||||
Imports []sourceImport
|
||||
Nodes []rendererNode
|
||||
Source []byte
|
||||
HeaderEnd int
|
||||
FunctionPos sourcePosition
|
||||
AST *ast.File
|
||||
}
|
||||
|
||||
type sourceImport struct {
|
||||
|
||||
+25
-22
@@ -114,16 +114,17 @@ func parseSource(path string, source []byte) (*sourceFile, []Diagnostic) {
|
||||
}
|
||||
|
||||
file := &sourceFile{
|
||||
Path: path,
|
||||
Mapping: filepath.ToSlash(filepath.Base(path)),
|
||||
Package: parsedHeader.Package,
|
||||
Name: parsedHeader.Name,
|
||||
TypeParams: parsedHeader.TypeParams,
|
||||
Params: parsedHeader.Params,
|
||||
Imports: parsedHeader.Imports,
|
||||
Source: source,
|
||||
HeaderEnd: headerClose + 2,
|
||||
AST: parsedHeader.AST,
|
||||
Path: path,
|
||||
Mapping: filepath.ToSlash(filepath.Base(path)),
|
||||
Package: parsedHeader.Package,
|
||||
Name: parsedHeader.Name,
|
||||
TypeParams: parsedHeader.TypeParams,
|
||||
Params: parsedHeader.Params,
|
||||
Imports: parsedHeader.Imports,
|
||||
Source: source,
|
||||
HeaderEnd: headerClose + 2,
|
||||
FunctionPos: parsedHeader.FunctionPos,
|
||||
AST: parsedHeader.AST,
|
||||
}
|
||||
|
||||
templateDiagnostics := tokenizeTemplate(file, source[headerClose+2:], headerClose+2, table)
|
||||
@@ -139,12 +140,13 @@ func isSpace(b byte) bool {
|
||||
}
|
||||
|
||||
type parsedHeader struct {
|
||||
Package string
|
||||
Name string
|
||||
TypeParams string
|
||||
Params string
|
||||
Imports []sourceImport
|
||||
AST *ast.File
|
||||
Package string
|
||||
Name string
|
||||
TypeParams string
|
||||
Params string
|
||||
Imports []sourceImport
|
||||
FunctionPos sourcePosition
|
||||
AST *ast.File
|
||||
}
|
||||
|
||||
func parseHeader(path string, declarations []byte, sourceOffset int, table positionTable) (*parsedHeader, []Diagnostic) {
|
||||
@@ -254,12 +256,13 @@ func parseHeader(path string, declarations []byte, sourceOffset int, table posit
|
||||
}
|
||||
|
||||
return &parsedHeader{
|
||||
Package: parsed.Name.Name,
|
||||
Name: function.Name.Name,
|
||||
TypeParams: typeParams,
|
||||
Params: params,
|
||||
Imports: imports,
|
||||
AST: parsed,
|
||||
Package: parsed.Name.Name,
|
||||
Name: function.Name.Name,
|
||||
TypeParams: typeParams,
|
||||
Params: params,
|
||||
Imports: imports,
|
||||
FunctionPos: table.at(sourceOffset + functionPosition.Offset),
|
||||
AST: parsed,
|
||||
}, diagnostics
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user