Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add LSP for FTL #1150

Merged
merged 6 commits into from
Apr 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 9 additions & 13 deletions go-runtime/compile/errors.go → backend/schema/errors.go
Original file line number Diff line number Diff line change
@@ -1,41 +1,37 @@
package compile
package schema

import (
"errors"
"fmt"
"go/ast"
"go/token"

"github.com/TBD54566975/ftl/backend/schema"
)

type Error struct {
Msg string
Pos schema.Position
Pos Position
Err error // Wrapped error, if any
}

func (e Error) Error() string { return fmt.Sprintf("%s: %s", e.Pos, e.Msg) }
func (e Error) Unwrap() error { return e.Err }

func errorf(pos token.Pos, format string, args ...any) Error {
return Error{Msg: fmt.Sprintf(format, args...), Pos: goPosToSchemaPos(pos)}
func Errorf(pos Position, format string, args ...any) Error {
return Error{Msg: fmt.Sprintf(format, args...), Pos: pos}
}

func wrapf(node ast.Node, err error, format string, args ...any) Error {
func Wrapf(pos Position, err error, format string, args ...any) Error {
if format == "" {
format = "%s"
} else {
format += ": %s"
}
// Propagate existing error position if available
var pos schema.Position
var newPos Position
if perr := (Error{}); errors.As(err, &perr) {
pos = perr.Pos
newPos = perr.Pos
args = append(args, perr.Msg)
} else {
pos = goPosToSchemaPos(node.Pos())
newPos = pos
args = append(args, err)
}
return Error{Msg: fmt.Sprintf(format, args...), Pos: pos, Err: err}
return Error{Msg: fmt.Sprintf(format, args...), Pos: newPos, Err: err}
}
23 changes: 21 additions & 2 deletions buildengine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ type schemaChange struct {
*schema.Module
}

type Listener struct {
OnBuildComplete func(project Project, err error)
OnDeployComplete func(project Project, err error)
}

// Engine for building a set of modules.
type Engine struct {
client ftlv1connect.ControllerServiceClient
Expand All @@ -39,6 +44,7 @@ type Engine struct {
schemaChanges *pubsub.Topic[schemaChange]
cancel func()
parallelism int
listener *Listener
}

type Option func(o *Engine)
Expand All @@ -49,6 +55,13 @@ func Parallelism(n int) Option {
}
}

// WithListener sets the event listener for the Engine.
func WithListener(listener *Listener) Option {
return func(o *Engine) {
o.listener = listener
}
}

// New constructs a new [Engine].
//
// Completely offline builds are possible if the full dependency graph is
Expand Down Expand Up @@ -356,7 +369,11 @@ func (e *Engine) buildAndDeploy(ctx context.Context, replicas int32, waitForDepl
return nil
}
if module, ok := project.(Module); ok {
if err := Deploy(ctx, module, replicas, waitForDeployOnline, e.client); err != nil {
err := Deploy(ctx, module, replicas, waitForDeployOnline, e.client)
if e.listener != nil && e.listener.OnDeployComplete != nil {
e.listener.OnDeployComplete(project, err)
}
if err != nil {
return err
}
}
Expand Down Expand Up @@ -457,8 +474,10 @@ func (e *Engine) build(ctx context.Context, key ProjectKey, builtModules map[str
sch := &schema.Schema{Modules: maps.Values(combined)}

err := Build(ctx, sch, project)
if e.listener != nil && e.listener.OnBuildComplete != nil {
e.listener.OnBuildComplete(project, err)
}
if err != nil {

return err
}
if module, ok := project.(Module); ok {
Expand Down
37 changes: 30 additions & 7 deletions cmd/ftl/cmd_dev.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,18 @@ import (
"github.com/TBD54566975/ftl/buildengine"
"github.com/TBD54566975/ftl/common/projectconfig"
"github.com/TBD54566975/ftl/internal/rpc"
"github.com/TBD54566975/ftl/lsp"
)

type devCmd struct {
Parallelism int `short:"j" help:"Number of modules to build in parallel." default:"${numcpu}"`
Dirs []string `arg:"" help:"Base directories containing modules." type:"existingdir" optional:""`
External []string `help:"Directories for libraries that require FTL module stubs." type:"existingdir" optional:""`
Watch time.Duration `help:"Watch template directory at this frequency and regenerate on change." default:"500ms"`
NoServe bool `help:"Do not start the FTL server." default:"false"`
ServeCmd serveCmd `embed:""`
Parallelism int `short:"j" help:"Number of modules to build in parallel." default:"${numcpu}"`
Dirs []string `arg:"" help:"Base directories containing modules." type:"existingdir" optional:""`
External []string `help:"Directories for libraries that require FTL module stubs." type:"existingdir" optional:""`
Watch time.Duration `help:"Watch template directory at this frequency and regenerate on change." default:"500ms"`
NoServe bool `help:"Do not start the FTL server." default:"false"`
RunLsp bool `help:"Run the language server." default:"false"`
ServeCmd serveCmd `embed:""`
languageServer *lsp.Server
}

func (d *devCmd) Run(ctx context.Context, projConfig projectconfig.Config) error {
Expand Down Expand Up @@ -56,7 +59,19 @@ func (d *devCmd) Run(ctx context.Context, projConfig projectconfig.Config) error
return err
}

engine, err := buildengine.New(ctx, client, d.Dirs, d.External, buildengine.Parallelism(d.Parallelism))
var listener *buildengine.Listener
if d.RunLsp {
d.languageServer = lsp.NewServer(ctx)
listener = &buildengine.Listener{
OnBuildComplete: d.OnBuildComplete,
OnDeployComplete: d.OnDeployComplete,
}
g.Go(func() error {
return d.languageServer.Run()
})
}

engine, err := buildengine.New(ctx, client, d.Dirs, d.External, buildengine.Parallelism(d.Parallelism), buildengine.WithListener(listener))
if err != nil {
return err
}
Expand All @@ -65,3 +80,11 @@ func (d *devCmd) Run(ctx context.Context, projConfig projectconfig.Config) error

return g.Wait()
}

func (d *devCmd) OnBuildComplete(project buildengine.Project, err error) {
d.languageServer.BuildComplete(project.Config().Dir, err)
}

func (d *devCmd) OnDeployComplete(project buildengine.Project, err error) {
d.languageServer.DeployComplete(project.Config().Dir, err)
}
26 changes: 17 additions & 9 deletions go-runtime/compile/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ type NativeNames map[schema.Decl]string

type enums map[string]*schema.Enum

func errorf(pos token.Pos, format string, args ...interface{}) schema.Error {
return schema.Errorf(goPosToSchemaPos(pos), format, args...)
}

func wrapf(pos token.Pos, err error, format string, args ...interface{}) schema.Error {
return schema.Wrapf(goPosToSchemaPos(pos), err, format, args...)
}

// ExtractModuleSchema statically parses Go FTL module source into a schema.Module.
func ExtractModuleSchema(dir string) (NativeNames, *schema.Module, error) {
pkgs, err := packages.Load(&packages.Config{
Expand All @@ -70,7 +78,7 @@ func ExtractModuleSchema(dir string) (NativeNames, *schema.Module, error) {
if len(pkg.Errors) > 0 {
for _, perr := range pkg.Errors {
if len(pkg.Syntax) > 0 {
merr = append(merr, wrapf(pkg.Syntax[0], perr, "%s", pkg.PkgPath))
merr = append(merr, wrapf(pkg.Syntax[0].Pos(), perr, "%s", pkg.PkgPath))
} else {
merr = append(merr, fmt.Errorf("%s: %w", pkg.PkgPath, perr))
}
Expand All @@ -81,7 +89,7 @@ func ExtractModuleSchema(dir string) (NativeNames, *schema.Module, error) {
err := goast.Visit(file, func(node ast.Node, next func() error) (err error) {
defer func() {
if err != nil {
err = wrapf(node, err, "")
err = wrapf(node.Pos(), err, "")
}
}()
switch node := node.(type) {
Expand Down Expand Up @@ -188,7 +196,7 @@ func parseConfigDecl(pctx *parseContext, node *ast.CallExpr, fn *types.Func) err
var err error
name, err = strconv.Unquote(literal.Value)
if err != nil {
return wrapf(node, err, "")
return wrapf(node.Pos(), err, "")
}
}
}
Expand Down Expand Up @@ -445,7 +453,7 @@ func visitFuncDecl(pctx *parseContext, node *ast.FuncDecl) (verb *schema.Verb, e
results := sig.Results()
reqt, respt, err := checkSignature(sig)
if err != nil {
return nil, wrapf(node, err, "")
return nil, wrapf(node.Pos(), err, "")
}
var req schema.Type
if reqt != nil {
Expand Down Expand Up @@ -602,12 +610,12 @@ func visitStruct(pctx *parseContext, pos token.Pos, tnode types.Type) (*schema.R
f := s.Field(i)
ft, err := visitType(pctx, f.Pos(), f.Type())
if err != nil {
return nil, errorf(pos, "field %s: %v", f.Name(), err)
return nil, errorf(f.Pos(), "field %s: %v", f.Name(), err)
}

// Check if field is exported
if len(f.Name()) > 0 && unicode.IsLower(rune(f.Name()[0])) {
return nil, errorf(pos, "params field %s must be exported by starting with an uppercase letter", f.Name())
return nil, errorf(f.Pos(), "params field %s must be exported by starting with an uppercase letter", f.Name())
}

// Extract the JSON tag and split it to get just the field name
Expand All @@ -621,13 +629,13 @@ func visitStruct(pctx *parseContext, pos token.Pos, tnode types.Type) (*schema.R
var metadata []schema.Metadata
if jsonFieldName != "" {
metadata = append(metadata, &schema.MetadataAlias{
Pos: goPosToSchemaPos(pos),
Pos: goPosToSchemaPos(f.Pos()),
Kind: schema.AliasKindJSON,
Alias: jsonFieldName,
})
}
out.Fields = append(out.Fields, &schema.Field{
Pos: goPosToSchemaPos(pos),
Pos: goPosToSchemaPos(f.Pos()),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these all needed to be f.Pos() since we're validating the s.Field(i) here.

Name: strcase.ToLowerCamel(f.Name()),
Type: ft,
Metadata: metadata,
Expand Down Expand Up @@ -746,7 +754,7 @@ func visitType(pctx *parseContext, pos token.Pos, tnode types.Type) (schema.Type
if underlying.String() == "any" {
return &schema.Any{Pos: goPosToSchemaPos(pos)}, nil
}
return nil, errorf(pos, "unsupported type %T", tnode)
return nil, errorf(pos, "unsupported type %q", tnode)

default:
return nil, errorf(pos, "unsupported type %T", tnode)
Expand Down
15 changes: 15 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ require (
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
github.com/swaggest/jsonschema-go v0.3.70
github.com/titanous/json5 v1.0.0
github.com/tliron/commonlog v0.2.17
github.com/tliron/glsp v0.2.2
github.com/tliron/kutil v0.3.24
github.com/tmc/langchaingo v0.1.8
github.com/zalando/go-keyring v0.2.4
go.opentelemetry.io/otel v1.24.0
Expand All @@ -56,10 +59,22 @@ require (
)

require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/iancoleman/strcase v0.3.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkoukk/tiktoken-go v0.1.6 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/sasha-s/go-deadlock v0.3.1 // indirect
github.com/segmentio/ksuid v1.0.4 // indirect
github.com/sourcegraph/jsonrpc2 v0.2.0 // indirect
go.opentelemetry.io/proto/otlp v1.1.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
)
Expand Down
31 changes: 31 additions & 0 deletions go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading