Skip to content

Commit

Permalink
feat: add lsp completion and hover handlers
Browse files Browse the repository at this point in the history
  • Loading branch information
wesbillman committed Jun 11, 2024
1 parent a34562b commit 4ca9ae2
Show file tree
Hide file tree
Showing 6 changed files with 380 additions and 0 deletions.
157 changes: 157 additions & 0 deletions lsp/completion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package lsp

import (
"os"
"strings"

"github.com/tliron/glsp"
protocol "github.com/tliron/glsp/protocol_3_16"
)

var (
snippetKind = protocol.CompletionItemKindSnippet
insertTextFormat = protocol.InsertTextFormatSnippet

verbCompletionItem = protocol.CompletionItem{
Label: "ftl:verb",
Kind: &snippetKind,
Detail: stringPtr("FTL Verb"),
InsertText: stringPtr(`type ${1:Request} struct {}
type ${2:Response} struct{}
//ftl:verb
func ${3:Name}(ctx context.Context, req ${1:Request}) (${2:Response}, error) {
return ${2:Response}{}, nil
}`),
Documentation: &protocol.MarkupContent{
Kind: protocol.MarkupKindMarkdown,
Value: `Snippet for defining a verb function.
` + "```go" + `
//ftl:verb
func Name(ctx context.Context, req Request) (Response, error) {}
` + "```",
},
InsertTextFormat: &insertTextFormat,
}

enumTypeCompletionItem = protocol.CompletionItem{
Label: "ftl:enum (sum type)",
Kind: &snippetKind,
Detail: stringPtr("FTL Enum (sum type)"),
InsertText: stringPtr(`//ftl:enum
type ${1:Enum} string
const (
${2:Value1} ${1:Enum} = "${2:Value1}"
${3:Value2} ${1:Enum} = "${3:Value2}"
)`),
Documentation: &protocol.MarkupContent{
Kind: protocol.MarkupKindMarkdown,
Value: `Snippet for defining a type enum.
` + "```go" + `
//ftl:enum
type MyEnum string
const (
Value1 MyEnum = "Value1"
Value2 MyEnum = "Value2"
)
` + "```",
},
InsertTextFormat: &insertTextFormat,
}

enumValueCompletionItem = protocol.CompletionItem{
Label: "ftl:enum (value)",
Kind: &snippetKind,
Detail: stringPtr("FTL enum (value type)"),
InsertText: stringPtr(`//ftl:enum
type ${1:Type} interface { ${2:interface}() }
type ${3:Value} struct {}
func (${3:Value}) ${2:interface}() {}
`),
Documentation: &protocol.MarkupContent{
Kind: protocol.MarkupKindMarkdown,
Value: `Snippet for defining a value enum value.
` + "```go" + `
//ftl:enum
type Animal interface { animal() }
type Cat struct {}
func (Cat) animal() {}
` + "```",
},
InsertTextFormat: &insertTextFormat,
}
)

var completionItems = []protocol.CompletionItem{
verbCompletionItem,
enumTypeCompletionItem,
enumValueCompletionItem,
}

func (s *Server) textDocumentCompletion() protocol.TextDocumentCompletionFunc {
return func(context *glsp.Context, params *protocol.CompletionParams) (interface{}, error) {
uri := params.TextDocument.URI
position := params.Position

doc, ok := s.documents.get(uri)
if !ok {
return nil, nil
}

line := int(position.Line - 1)
if line >= len(doc.lines) {
return nil, nil
}

lineContent := doc.lines[line]
character := int(position.Character - 1)
if character > len(lineContent) {
character = len(lineContent)
}

prefix := lineContent[:character]

// Filter completion items based on the prefix
var filteredItems []protocol.CompletionItem
for _, item := range completionItems {
if strings.HasPrefix(item.Label, prefix) || strings.Contains(item.Label, prefix) {
filteredItems = append(filteredItems, item)
}
}

return &protocol.CompletionList{
IsIncomplete: false,
Items: filteredItems,
}, nil
}
}

func (s *Server) completionItemResolve() protocol.CompletionItemResolveFunc {
return func(context *glsp.Context, params *protocol.CompletionItem) (*protocol.CompletionItem, error) {
if path, ok := params.Data.(string); ok {
content, err := os.ReadFile(path)
if err != nil {
return nil, err
}

params.Documentation = &protocol.MarkupContent{
Kind: protocol.MarkupKindMarkdown,
Value: string(content),
}
}

return params, nil
}
}

func stringPtr(v string) *string {
s := v
return &s
}
60 changes: 60 additions & 0 deletions lsp/document.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package lsp

import (
"strings"

protocol "github.com/tliron/glsp/protocol_3_16"
)

// document represents a document that is open in the editor.
// The Content and lines are parsed when the document is opened or changed to avoid having to perform
// the same operation multiple times and to keep the interaction snappy for hover operations.
type document struct {
uri protocol.DocumentUri
Content string
lines []string
}

// documentStore is a simple in-memory store for documents that are open in the editor.
// Its primary purpose it to provide quick access to open files for operations like hover.
// Rather than reading the file from disk, we can get the document from the store.
type documentStore struct {
documents map[protocol.DocumentUri]*document
}

func newDocumentStore() *documentStore {
return &documentStore{
documents: make(map[protocol.DocumentUri]*document),
}
}

func (ds *documentStore) get(uri protocol.DocumentUri) (*document, bool) {
doc, ok := ds.documents[uri]
return doc, ok
}

func (ds *documentStore) set(uri protocol.DocumentUri, content string) {
ds.documents[uri] = &document{
uri: uri,
Content: content,
lines: strings.Split(content, "\n"),
}
}

func (ds *documentStore) delete(uri protocol.DocumentUri) {
delete(ds.documents, uri)
}

func (d *document) update(changes []interface{}) {
for _, change := range changes {
switch c := change.(type) {
case protocol.TextDocumentContentChangeEvent:
startIndex, endIndex := c.Range.IndexesIn(d.Content)
d.Content = d.Content[:startIndex] + c.Text + d.Content[endIndex:]
case protocol.TextDocumentContentChangeEventWhole:
d.Content = c.Text
}
}

d.lines = strings.Split(d.Content, "\n")
}
57 changes: 57 additions & 0 deletions lsp/hover.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package lsp

import (
_ "embed"
"strings"

"github.com/tliron/glsp"
protocol "github.com/tliron/glsp/protocol_3_16"
)

//go:embed markdown/verb.md
var verbHoverContent string

//go:embed markdown/enum.md
var enumHoverContent string

var hoverMap = map[string]string{
"//ftl:verb": verbHoverContent,
"//ftl:enum": enumHoverContent,
}

func (s *Server) textDocumentHover() protocol.TextDocumentHoverFunc {
return func(context *glsp.Context, params *protocol.HoverParams) (*protocol.Hover, error) {
uri := params.TextDocument.URI
position := params.Position

doc, ok := s.documents.get(uri)
if !ok {
return nil, nil
}

line := int(position.Line)
if line >= len(doc.lines) {
return nil, nil
}

lineContent := doc.lines[line]
character := int(position.Character)
if character > len(lineContent) {
character = len(lineContent)
}

for hoverString, hoverContent := range hoverMap {
startIndex := strings.Index(lineContent, hoverString)
if startIndex != -1 && startIndex <= character && character <= startIndex+len(hoverString) {
return &protocol.Hover{
Contents: &protocol.MarkupContent{
Kind: protocol.MarkupKindMarkdown,
Value: hoverContent,
},
}, nil
}
}

return nil, nil
}
}
56 changes: 56 additions & 0 deletions lsp/lsp.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type Server struct {
handler protocol.Handler
logger log.Logger
diagnostics *xsync.MapOf[protocol.DocumentUri, []protocol.Diagnostic]
documents *documentStore
}

// NewServer creates a new language server.
Expand All @@ -39,13 +40,24 @@ func NewServer(ctx context.Context) *Server {
SetTrace: setTrace,
LogTrace: logTrace,
}

s := glspServer.NewServer(&handler, lsName, false)
server := &Server{
server: s,
logger: *log.FromContext(ctx).Scope("lsp"),
diagnostics: xsync.NewMapOf[protocol.DocumentUri, []protocol.Diagnostic](),
documents: newDocumentStore(),
}

handler.TextDocumentDidOpen = server.textDocumentDidOpen()
handler.TextDocumentDidChange = server.textDocumentDidChange()
handler.TextDocumentDidClose = server.textDocumentDidClose()
handler.TextDocumentDidSave = server.textDocumentDidSave()
handler.TextDocumentCompletion = server.textDocumentCompletion()
handler.CompletionItemResolve = server.completionItemResolve()
handler.TextDocumentHover = server.textDocumentHover()
handler.Initialize = server.initialize()

return server
}

Expand Down Expand Up @@ -179,6 +191,15 @@ func (s *Server) initialize() protocol.InitializeFunc {
}

serverCapabilities := s.handler.CreateServerCapabilities()
serverCapabilities.TextDocumentSync = protocol.TextDocumentSyncKindIncremental
serverCapabilities.HoverProvider = true

trueValue := true
serverCapabilities.CompletionProvider = &protocol.CompletionOptions{
ResolveProvider: &trueValue,
TriggerCharacters: []string{"/", "f"},
}

return protocol.InitializeResult{
Capabilities: serverCapabilities,
ServerInfo: &protocol.InitializeResultServerInfo{
Expand Down Expand Up @@ -207,6 +228,41 @@ func setTrace(context *glsp.Context, params *protocol.SetTraceParams) error {
return nil
}

func (s *Server) textDocumentDidOpen() protocol.TextDocumentDidOpenFunc {
return func(context *glsp.Context, params *protocol.DidOpenTextDocumentParams) error {
uri := params.TextDocument.URI
content := params.TextDocument.Text
s.documents.set(uri, content)
return nil
}
}

func (s *Server) textDocumentDidChange() protocol.TextDocumentDidChangeFunc {
return func(context *glsp.Context, params *protocol.DidChangeTextDocumentParams) error {
doc, ok := s.documents.get(params.TextDocument.URI)
if !ok {
return nil
}

doc.update(params.ContentChanges)
return nil
}
}

func (s *Server) textDocumentDidClose() protocol.TextDocumentDidCloseFunc {
return func(context *glsp.Context, params *protocol.DidCloseTextDocumentParams) error {
uri := params.TextDocument.URI
s.documents.delete(uri)
return nil
}
}

func (s *Server) textDocumentDidSave() protocol.TextDocumentDidSaveFunc {
return func(context *glsp.Context, params *protocol.DidSaveTextDocumentParams) error {
return nil
}
}

// getLineOrWordLength returns the length of the line or the length of the word starting at the given column.
// If wholeLine is true, it returns the length of the entire line.
// If wholeLine is false, it returns the length of the word starting at the column.
Expand Down
31 changes: 31 additions & 0 deletions lsp/markdown/enum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
## Type enums (sum types)

[Sum types](https://en.wikipedia.org/wiki/Tagged_union) are supported by FTL's type system, but aren't directly supported by Go. However they can be approximated with the use of [sealed interfaces](https://blog.chewxy.com/2018/03/18/golang-interfaces/). To declare a sum type in FTL use the comment directive `//ftl:enum`:

```go
//ftl:enum
type Animal interface { animal() }

type Cat struct {}
func (Cat) animal() {}

type Dog struct {}
func (Dog) animal() {}
```

## Value enums

A value enum is an enumerated set of string or integer values.

```go
//ftl:enum
type Colour string

const (
Red Colour = "red"
Green Colour = "green"
Blue Colour = "blue"
)
```

[Reference](https://tbd54566975.github.io/ftl/docs/reference/types/)
Loading

0 comments on commit 4ca9ae2

Please sign in to comment.