// 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("")) if open >= 0 && open > close && bytes.HasPrefix(document.Text[open:offset], []byte("", InsertTextFormat: 2, SortText: "0-header"}, {Label: "", Kind: 15, Detail: "Go statement", InsertText: "", InsertTextFormat: 2, SortText: "0-statement"}, {Label: "", Kind: 15, Detail: "contextually escaped expression", InsertText: "", InsertTextFormat: 2, SortText: "0-expression"}, {Label: "", Kind: 15, Detail: "typed component composition", InsertText: "", InsertTextFormat: 2, SortText: "0-component"}, {Label: "", Kind: 15, Detail: "Hime-san template comment", InsertText: "", 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 "`** declares the Go package, imports, and one typed component signature. It must be the first non-whitespace content." case "`** composes a typed `sando.Component` at an HTML content boundary. It is not template inheritance." case "`** renders an expression through the helper selected by Hime-san's inferred HTML output context." case "`** is a Hime-san comment. It emits no bytes and cannot change HTML parser state." case "`** 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 closeRelative < 0 || open+closeRelative+2 < offset { return "", 0, 0 } end := open + closeRelative + 2 marker := " 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 }