diff --git a/lsp/completion.go b/lsp/completion.go new file mode 100644 index 0000000000..08a138e0e8 --- /dev/null +++ b/lsp/completion.go @@ -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 +} diff --git a/lsp/document.go b/lsp/document.go new file mode 100644 index 0000000000..6421618b12 --- /dev/null +++ b/lsp/document.go @@ -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") +} diff --git a/lsp/hover.go b/lsp/hover.go new file mode 100644 index 0000000000..d2b22ddd13 --- /dev/null +++ b/lsp/hover.go @@ -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 + } +} diff --git a/lsp/lsp.go b/lsp/lsp.go index 81dfa2cd0f..f255d9e8ff 100644 --- a/lsp/lsp.go +++ b/lsp/lsp.go @@ -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. @@ -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 } @@ -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{ @@ -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. diff --git a/lsp/markdown/completion/enumType.md b/lsp/markdown/completion/enumType.md new file mode 100644 index 0000000000..9a733298d0 --- /dev/null +++ b/lsp/markdown/completion/enumType.md @@ -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" +) +``` diff --git a/lsp/markdown/completion/enumValue.md b/lsp/markdown/completion/enumValue.md new file mode 100644 index 0000000000..f8684eed17 --- /dev/null +++ b/lsp/markdown/completion/enumValue.md @@ -0,0 +1,9 @@ +Snippet for defining a value enum. + +```go +//ftl:enum +type Animal interface { animal() } + +type Cat struct {} +func (Cat) animal() {} +``` diff --git a/lsp/markdown/completion/verb.md b/lsp/markdown/completion/verb.md new file mode 100644 index 0000000000..68fc2f8e66 --- /dev/null +++ b/lsp/markdown/completion/verb.md @@ -0,0 +1,6 @@ +Snippet for defining a verb function. + +```go +//ftl:verb +func Name(ctx context.Context, req Request) (Response, error) {} +``` diff --git a/lsp/markdown/hover/enum.md b/lsp/markdown/hover/enum.md new file mode 100644 index 0000000000..0262bf23f7 --- /dev/null +++ b/lsp/markdown/hover/enum.md @@ -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/) diff --git a/lsp/markdown/hover/verb.md b/lsp/markdown/hover/verb.md new file mode 100644 index 0000000000..7d8e9d0f27 --- /dev/null +++ b/lsp/markdown/hover/verb.md @@ -0,0 +1,19 @@ +## Verb + +Verbs are the function primitives of FTL. They take a single value and return a single value or an error. + +`F(X) -> Y` + +eg. + +```go +type EchoRequest struct {} +type EchoResponse struct {} + +//ftl:verb +func Echo(ctx context.Context, in EchoRequest) (EchoResponse, error) { + // ... +} +``` + +[Reference](https://tbd54566975.github.io/ftl/docs/reference/verbs/)