Skip to content

Commit

Permalink
feat: add lsp completion and hover handlers (#1724)
Browse files Browse the repository at this point in the history
See #1533 

Still needs some work to link to console, but these are nice DevX
improvements for FTL/VSCode users.



https://github.com/TBD54566975/ftl/assets/51647/949201f9-6ea6-40d2-abd9-6cfb6395f106
  • Loading branch information
wesbillman authored Jun 11, 2024
1 parent 6b46e9b commit 4ac5ecc
Show file tree
Hide file tree
Showing 9 changed files with 393 additions and 0 deletions.
144 changes: 144 additions & 0 deletions lsp/completion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package lsp

import (
_ "embed"
"os"
"strings"

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

//go:embed markdown/completion/verb.md
var verbCompletionDocs string

//go:embed markdown/completion/enumType.md
var enumTypeCompletionDocs string

//go:embed markdown/completion/enumValue.md
var enumValueCompletionDocs string

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: verbCompletionDocs,
},
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: enumTypeCompletionDocs,
},
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: enumValueCompletionDocs,
},
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/hover/verb.md
var verbHoverContent string

//go:embed markdown/hover/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
11 changes: 11 additions & 0 deletions lsp/markdown/completion/enumType.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Snippet for defining a type enum (sum types).

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

const (
Value1 MyEnum = "Value1"
Value2 MyEnum = "Value2"
)
```
9 changes: 9 additions & 0 deletions lsp/markdown/completion/enumValue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Snippet for defining a value enum.

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

type Cat struct {}
func (Cat) animal() {}
```
6 changes: 6 additions & 0 deletions lsp/markdown/completion/verb.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Snippet for defining a verb function.

```go
//ftl:verb
func Name(ctx context.Context, req Request) (Response, error) {}
```
Loading

0 comments on commit 4ac5ecc

Please sign in to comment.