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
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ func TestSupervisorBuildsSwapsAndCleansUp(t *testing.T) {
|
||||
t.Skip("integration test builds temporary Go applications")
|
||||
}
|
||||
root := t.TempDir()
|
||||
disableParentVCSStamping(t)
|
||||
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/himesan-dev-test\n\ngo 1.25\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -41,7 +42,10 @@ func TestSupervisorBuildsSwapsAndCleansUp(t *testing.T) {
|
||||
generations.Add(1)
|
||||
return nil
|
||||
},
|
||||
OnEvent: func(event Event) { events <- event },
|
||||
OnEvent: func(event Event) {
|
||||
t.Logf("supervisor event: type=%s phase=%s message=%s", event.Type, event.Phase, event.Message)
|
||||
events <- event
|
||||
},
|
||||
CacheDir: filepath.Join(t.TempDir(), "cache"),
|
||||
PollInterval: 25 * time.Millisecond,
|
||||
Debounce: 25 * time.Millisecond,
|
||||
@@ -101,6 +105,7 @@ func TestSupervisorClearsTargetWhenCurrentApplicationExits(t *testing.T) {
|
||||
t.Skip("integration test builds a temporary Go application")
|
||||
}
|
||||
root := t.TempDir()
|
||||
disableParentVCSStamping(t)
|
||||
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/himesan-dev-exit-test\n\ngo 1.25\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -112,10 +117,13 @@ func TestSupervisorClearsTargetWhenCurrentApplicationExits(t *testing.T) {
|
||||
cfg.HealthPath = "/healthz"
|
||||
events := make(chan Event, 16)
|
||||
supervisor, err := New(Options{
|
||||
RootDir: root,
|
||||
Config: cfg,
|
||||
Generate: func(context.Context) error { return nil },
|
||||
OnEvent: func(event Event) { events <- event },
|
||||
RootDir: root,
|
||||
Config: cfg,
|
||||
Generate: func(context.Context) error { return nil },
|
||||
OnEvent: func(event Event) {
|
||||
t.Logf("supervisor event: type=%s phase=%s message=%s", event.Type, event.Phase, event.Message)
|
||||
events <- event
|
||||
},
|
||||
CacheDir: filepath.Join(t.TempDir(), "cache"),
|
||||
PollInterval: 30 * time.Second,
|
||||
Debounce: 25 * time.Millisecond,
|
||||
@@ -163,6 +171,15 @@ func TestSupervisorClearsTargetWhenCurrentApplicationExits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func disableParentVCSStamping(t *testing.T) {
|
||||
t.Helper()
|
||||
// A temporary standalone module can live under a parent directory that is
|
||||
// itself a VCS checkout (including hardened test sandboxes). Its candidate
|
||||
// must not inherit or depend on that unrelated repository's status.
|
||||
flags := strings.TrimSpace(os.Getenv("GOFLAGS") + " -buildvcs=false")
|
||||
t.Setenv("GOFLAGS", flags)
|
||||
}
|
||||
|
||||
func TestGenerationFailureDoesNotMoveProxyTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
upstream := http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ func TestSelectCompilerVersion(t *testing.T) {
|
||||
{name: "missing build info", linkerValue: developmentCompilerVersion, moduleVersion: "", want: developmentCompilerVersion},
|
||||
{name: "versioned go install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0", want: "v1.0.0"},
|
||||
{name: "beta launch install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-beta.1", want: "v1.0.0-beta.1"},
|
||||
{name: "beta two install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-beta.2", want: "v1.0.0-beta.2"},
|
||||
{name: "versioned prerelease install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-rc.1", want: "v1.0.0-rc.1"},
|
||||
{name: "hyphenated prerelease install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.2.3-beta-2", want: "v1.2.3-beta-2"},
|
||||
{name: "pseudo version", linkerValue: developmentCompilerVersion, moduleVersion: "v0.0.0-20260811120000-0123456789ab", want: developmentCompilerVersion},
|
||||
@@ -30,6 +31,7 @@ func TestSelectCompilerVersion(t *testing.T) {
|
||||
{name: "leading zero numeric beta identifier", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-beta.01", want: developmentCompilerVersion},
|
||||
{name: "empty beta identifier", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-beta..1", want: developmentCompilerVersion},
|
||||
{name: "beta linker override wins", linkerValue: "v1.0.0-beta.1", moduleVersion: "(devel)", want: "v1.0.0-beta.1"},
|
||||
{name: "beta two linker override wins", linkerValue: "v1.0.0-beta.2", moduleVersion: "(devel)", want: "v1.0.0-beta.2"},
|
||||
{name: "linker override wins", linkerValue: "v1.0.0-rc.1", moduleVersion: "v1.0.0", want: "v1.0.0-rc.1"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
|
||||
Reference in New Issue
Block a user