Skip to content

Commit

Permalink
feat: Add LSP for FTL (#1150)
Browse files Browse the repository at this point in the history
Add an LSP that produces document highlights when FTL outputs build
errors

this is a work in progress

---------

Co-authored-by: Wes <[email protected]>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Apr 2, 2024
1 parent 5f11007 commit 812adb7
Show file tree
Hide file tree
Showing 8 changed files with 363 additions and 31 deletions.
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()),
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

0 comments on commit 812adb7

Please sign in to comment.